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

为 Windows Phone 7 应用程序实现向导功能

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (11投票s)

2011 年 7 月 1 日

CPOL

23分钟阅读

viewsIcon

41650

downloadIcon

700

本文介绍了如何为 WP7 应用程序构建一个向导。

Sample Image

引言

最近,我需要在 WP7 平台上实现一个向导。我在互联网上到处寻找现成的解决方案,但一无所获。因此,我决定自己实现一个。完成之后,我觉得这个向导运行良好,所以我决定分享它。本文介绍了我在这个主题上的工作(WP7 平台的向导设计考虑和实现)。

本文中实现的这些向导类非常灵活。虽然它们是为 WP7 编写的,但这些类也可以在 WPF 和 Silverlight 项目中使用,而无需任何额外的修改。

文章目录

设计考虑因素

设计向导时需要考虑一些事项。这些事项既与 UI 相关,也与功能相关。关于 UI,一个关键的决定是如何在屏幕上显示向导。在观看了介绍 Pivot 和 Panorama 控件的视频后,很明显向导不应该托管在这些控件中。我剩下两个选择:在单独的页面中实现每个步骤,或者在一个页面中实现向导。我选择了第二个选项。下面将讨论向导应该具备的功能。

一个向导应该

  • 有一个标题
  • 管理一个向导步骤集合
  • 向用户暴露当前步骤
  • 允许用户指定向导是否可以取消

一个向导步骤应该

  • 有一个标题
  • 允许用户指定在通过当前步骤后是否可以返回当前步骤
  • 允许用户指定是否可以在当前步骤完成向导,而无需通过剩余的步骤
  • 具有用户可以设置的内容

向导实现

我实现的向导功能由两个类和一个接口组成。这些元素可以在下图中看到

在上面的图片中,Wizard 类代表实际的向导。这个类将用于管理所有向导步骤。WizardStep 类代表一个实际的向导步骤。IValidableContent 接口用于验证。WizardStep 类实现了这个接口以支持验证。当需要为向导步骤,特别是向导步骤内部的内容提供验证时,也应该实现这个接口。下一节将详细讨论这些组件中的每一个。

IValidableContent 接口

此接口用于验证目的。通过在表示向导步骤内容的类中实现此接口,用户可以覆盖该步骤的默认验证行为。接口图可以在下图中看到

该接口公开了一个方法 IsValid,它将包含验证逻辑。如果类没有实现此接口,那么包含这些类实例的向导步骤默认将被视为有效。如果表示向导步骤内容的类实现了该接口,那么步骤的有效性将由 IsValid 方法返回的值决定。

WizardStep 类

此类表示一个向导步骤。类图可以在下图中看到

该类有 5 个属性,如下所述

  • AllowFinish – 此布尔属性用于指示是否可以从此步骤完成向导。在某些情况下,向导已经填充了默认值。如果用户不想更改这些值,则不应强制他通过每个向导步骤才能完成向导。当此属性在当前向导步骤上设置为 true 时,向导将显示一个完成按钮,允许用户完成向导。如果设置为 false,则不会显示完成按钮。
  • AllowReturn – 此布尔属性用于指示用户是否可以通过后退命令返回此步骤。如果此属性在向导步骤上设置为 false 且用户已通过该步骤,则用户无法转到上一步。事实上,当用户希望转到上一步时,他将被发送到第一个(按相反顺序)将 AllowReturn 属性设置为 true 的步骤。
  • Title – 此属性表示步骤标题
  • Wizard – 此属性表示该步骤所属的向导。
  • Content – 这是该类最重要的属性。此属性用于保存向导步骤的内容。在大多数情况下,这应该是一个自定义的 ViewModel 类。

WizardStep 类还实现了 IValidableContent 接口。此接口用于提供步骤验证。向导步骤中的实现会检查 Content 属性是否实现了相同的接口。如果实现了,它将返回该接口实现返回的值。如果 Content 属性的类型没有实现 IValidableContent 接口,则该步骤默认被认为是有效的。代码如下所示

public bool IsValid()
{
	IValidableContent v = content as IValidableContent;
	if (v != null)
		return v.IsValid();
	return true;
}

关于这个类,我想谈的最后一点是内容更改通知。由于 WizardStep 类实现了 INotifyPropertyChanged,如果属性发生更改,则会通知更改。这包括 Content 属性更改的情况。问题是,如果内容上的属性更改了,但 Content 实例本身没有更改,会发生什么?这个问题对于验证很重要。如果步骤的 Content 属性已设置,并且步骤最初是无效的,会发生什么?用户修改一个属性以使步骤有效后,向导状态应该失效以反映新的内容状态,并让用户有可能进入下一步。这些更改需要通知向导步骤,以便它可以告诉向导使命令失效。还有关于内容层次结构有多深的问题,以及您应该处理所有级别还是只处理层次结构中的第一级。还有一个问题是向导步骤事先不知道内容的结构。

我决定使用反射来实现这一点。每当在向导步骤上设置 Content 属性时,该步骤就会订阅内容层次结构中所有级别的更改通知。这是通过以下方法实现的

private void subscribe(object obj)
{
	INotifyPropertyChanged c = obj as INotifyPropertyChanged;
	if (c != null)
		c.PropertyChanged += ContentChanged;
	else
		return;
	Debug.WriteLine("subscribed " + obj);
	Type type = obj.GetType();
	//get all the public properties
	PropertyInfo[] pis = type.GetProperties();
	//iterate over them and call subscribe()
	for (int i = 0; i < pis.Length; i++)
	{
		object val = pis[i].GetValue(obj, null);
		//call subscribe() recursively even if it does not
		//implement inpc. this will be checked anyway the next time
		//subscribe() is called
		subscribe(val);
	}
} 

该方法检查对象是否实现了 INotifyPropertyChanged 接口。如果实现了,它会订阅该对象的 PropertyChanged 事件,然后递归检查对象的属性。如果对象没有实现 INotifyPropertyChanged 接口,则该方法返回。

Content 属性的定义可以在下面的列表中看到

public object Content
{
	get { return content; }
	set
	{
		if (content == value)
			return;
		//unsubscribe the old content
		unsubscribe(content);
		//set the new content
		content = value;
		//subscribe the new content
		subscribe(content);
		NotifyPropertyChanged("Content");
		//also invalidate the commands
		if (wizard != null)
			wizard.Invalidate();
	}
}

正如你所看到的,在设置新内容之前,该属性使用 unsubscribe() 方法取消订阅当前内容。在此之后,设置新内容,并且向导步骤使用上述 subscribe() 方法订阅更改通知。向导也会失效以反映新内容的状态。

unsubscribe() 方法的定义类似,可以在下面的列表中看到

private void unsubscribe(object obj)
{
	INotifyPropertyChanged c = obj as INotifyPropertyChanged;
	if (c != null)
		c.PropertyChanged -= ContentChanged;
	else
		return;
	Debug.WriteLine("unsubscribed "+ obj);
	Type type = obj.GetType();
	//get all the public properties
	PropertyInfo[] pis = type.GetProperties();
	//iterate over them and call unsubscribe()
	for (int i = 0; i < pis.Length; i++)
	{
		object val = pis[i].GetValue(obj, null);
		//call unsubscribe() recursively even if it does not
		//implement inpc. this will be checked anyway the next time
		//unsubscribe() is called
		unsubscribe(val);
	}
}

Wizard 类

此类表示向导本身,是向导步骤的容器。类图可以在下图中看到

该类有 4 个属性,如下所述

  • Title – 表示向导标题
  • Steps – 这是一个类型为 ObservableCollection<WizardStep> 的集合,表示向导步骤
  • CurrentStep – 表示当前向导步骤
  • ShowCancel – 此属性用于指定向导是否可以取消。当此属性为 true 时,向导将显示一个取消按钮,可用于取消向导。如果此属性为 false,则不会显示取消按钮。

向导还公开了 2 个事件。当向导完成和取消时(前提是 ShowCancel 属性设置为 true),会触发这些事件。

该类还有另外 4 个属性和 4 个与向导导航相关的方法。这些将在下面的段落中描述。

导航属性

这些属性指定向导是否可以沿特定方向导航。其中第一个属性是 CanNext。顾名思义,该属性返回用户是否可以移动到下一步。定义如下所示

public bool CanGoForward
{
	get
	{
		//can always move forward if valid
		int idx = steps.IndexOf(currentStep);
		return idx < (steps.Count - 1) && CurrentStep.IsValid();
	}
}

正如您从上面的定义中看到的,只要当前步骤不是最后一步,并且当前步骤有效,用户就可以始终前进。

第二个属性是 CanPrevious。此属性指示向导是否可以移动到上一步。定义如下所示

public bool CanGoBack
{
	get
	{
		WizardStepViewModel prev = GetPreviousAvailableStep();
		return prev != null;
	}
}

该属性使用 GetPreviousAvailableStep() 方法来确定上一步。如果存在可用的上一步,此方法将返回它,并且该属性将返回 trueGetPreviousAvailableStep 方法的定义可以在下面看到

private WizardStepViewModel GetPreviousAvailableStep()
{
	int idx = steps.IndexOf(currentStep);
	for (int i = idx - 1; i >= 0; i--)
	{
		if (steps.ElementAt(i).AllowReturn)
			return steps.ElementAt(i);
	}
	return null;
}

该方法以倒序检查所有先前的步骤,并返回第一个将 AllowReturn 属性设置为 true 的步骤。此属性指示用户是否可以返回该步骤。

第三个属性是 CanCancel 属性。此属性指定向导是否可以取消。定义如下所示

public bool CanCancel
{
	get
	{
		//can cancel only if show cancel is true and there are steps
		return steps.Count > 0 && ShowCancel;
	}
}

如您所见,如果向导中有步骤,并且 ShowCancel 属性设置为 true,此方法始终返回 true

最后一个与导航相关的属性是 CanFinish。此属性指定向导是否可以完成。此属性的定义可以在下面看到

public bool CanFinish
{
	get
	{
		//can only finish if the user is on the last step
		//derived classes can say otherwise
		int idx = steps.IndexOf(currentStep);
		if (steps.Count == 0 || currentStep == null || !currentStep.IsValid())
			return false;
		if (idx < steps.Count - 1 && currentStep.AllowFinish)
			return true;
		return idx == steps.Count - 1;
	}
}

只有当当前步骤有效且是最后一个向导步骤,或者当前步骤有效且 AllowFinish 属性设置为 true 时,用户才能完成向导。

导航方法

第一个导航方法是 Next() 方法。此方法负责将向导移动到下一步。定义如下所示

public virtual void Next()
{
	if (CanNext)
	{
		//move to the next step
		int idx = steps.IndexOf(currentStep);
		CurrentStep = steps.ElementAt(idx + 1);
	}
}

首先要注意的是,该方法被声明为 virtual。这将使用户能够覆盖该方法并在派生类中提供附加功能。本文后面将提供一个示例。该方法唯一做的就是将下一步设置为当前步骤。

第二个方法是 Previous() 方法。此方法负责将向导移动到上一步。定义如下所示

public virtual void OnPrevious()
{
	//take into account the allowReturn value
	WizardStepViewModel prev = GetPreviousAvailableStep();
	if (prev != null)
	{
		CurrentStep = prev;
	}
}

该方法还使用 GetPreviousAvailableStep() 来检索可用步骤,并将其设置为当前步骤。

第三个方法是 Cancel() 方法。此方法用于取消向导。如果用户可以取消向导,则此方法会引发 WizardCalcelled 事件。此方法的定义可以在下面的列表中看到

public virtual void OnCancel(object param)
{
	if (CanCancel)
		OnWizardCanceled();
}

最后一个导航方法是 Finish() 方法。如果向导可以完成,此方法会引发 WizardFinished 事件。定义可以在下面的列表中看到

public virtual void OnFinish(object param)
{
	if (CanFinish)
		OnWizardFinished();
}

辅助方法 (Helper Methods)

Wizard 类还公开了一些用于向向导添加步骤的方法。这些方法可以在下面的列表中看到

public void AddStep(WizardStepViewModel step)
{
	if (step.Wizard != null && step.Wizard != this)
		step.Wizard.Steps.Remove(step);
	if (step.Wizard == this)
		return;
	step.Wizard = this;
	Steps.Add(step);
}
public void AddStep(string title, object content)
{
	AddStep(title, content, false, true);
}
public void AddStep(string title, object content, bool allowFinish, bool allowReturn)
{
	WizardStepViewModel vm = new WizardStepViewModel();
	vm.Title = title;
	vm.Content = content;
	vm.Wizard = this;
	vm.AllowFinish = allowFinish;
	vm.AllowReturn = allowReturn;
	Steps.Add(vm);
}

由于向导步骤只能有一个父级,因此第一个方法重载首先检查要添加的步骤是否已经有不同的父级。如果有,则将其删除。之后,它设置新父级并添加步骤。另外两个重载通过一组参数添加新步骤。

最后一个值得一提的辅助方法是 Invalidate 方法。这是一个虚拟方法,当向导失效时会被调用。此方法的定义如下所示

public virtual void Invalidate()
{
	NotifyPropertyChanged("CanNext");
	NotifyPropertyChanged("CanPrevious");
	NotifyPropertyChanged("CanCancel");
	NotifyPropertyChanged("CanFinish");
} 

在此级别所需要做的就是通知导航属性已更改。派生类可以通过覆盖该方法来添加更多功能。

使用向导类

在最简单的情况下,向导类可以在没有任何修改的情况下使用。唯一的要求是提供适当的模板,并将这些类集成到特定应用程序的代码中。向导类可以轻松地与各种 MVVM 框架集成。本文随附的示例使用 MVVM Light 和 Caliburn Micro 将向导类集成到各种应用程序中。本文中的示例将仅展示 MVVM Light 集成。

基本用法(MVVM Light)

第一个用法示例将原封不动地使用向导类。此示例将展示一个包含 3 个步骤的向导。向导将允许用户指定一个人的详细信息。第一步将显示姓名,第二步将显示地址和电子邮件。地址是一个复杂类型,将包含街道和城市。此步骤还覆盖了默认验证逻辑。最后一步用于显示个人简介。

为了成功使用向导,我们需要做以下事情

  • 定义每个步骤的内容
  • 创建向导并用前面定义的内容填充每个步骤
  • 为向导及其步骤创建视图
  • 将向导绑定到页面上的控件

定义每个步骤的内容

就像本节开头所说,我们需要定义向导为每个步骤呈现的内容。对于这个特殊的向导,我们将定义 4 个视图模型。第一步的视图模型可以在下面的列表中看到

public class FirstPageViewModel:ViewModelBase
{
	private string firstName;

	public string FirstName
	{
		get { return firstName; }
		set
		{
			if (firstName == value)
				return;
			firstName = value;
			RaisePropertyChanged("FirstName");
		}
	}
	private string lastName;

	public string LastName
	{
		get { return lastName; }
		set
		{
			if (lastName == value)
				return;
			lastName = value;
			RaisePropertyChanged("LastName");
		}
	}
}

第二步的视图模型可以在下面的列表中看到

public class SecondPageViewModel:ViewModelBase,IValidableContent
{
	public SecondPageViewModel()
	{
		Address = new AddressViewModel();
	}
	private string email;

	public string Email
	{
		get { return email; }
		set
		{
			if (email == value)
				return;
			email = value;
			RaisePropertyChanged("Email");
		}
	}
	private AddressViewModel addr;

	public AddressViewModel Address
	{
		get { return addr; }
		set
		{
			if (addr == value)
				return;
			addr = value;
			RaisePropertyChanged("Address");
		}
	}

	public bool IsValid()
	{
		return !string.IsNullOrEmpty(email) &&
			Address != null && !string.IsNullOrEmpty(Address.Street) &&
			!string.IsNullOrEmpty(Address.City);
	}
}

此第二个视图模型还通过实现 IValidableContent 接口来覆盖默认验证行为。内容仅在 Address 不为 null 且地址字段包含数据时才有效。地址的视图模型可以在下面的列表中看到

public class AddressViewModel:ViewModelBase
{
	private string str;

	public string Street
	{
		get { return str; }
		set
		{
			if (str == value)
				return;
			str = value;
			RaisePropertyChanged("Street");
		}
	}
	private string city;

	public string City
	{
		get { return city; }
		set
		{
			if (city == value)
				return;
			city = value;
			RaisePropertyChanged("City");
		}
	}
}

第三步的视图模型可以在下面的列表中看到

public class ThirdPageViewModel:ViewModelBase
{
	private string bio;

	public string Biography
	{
		get { return bio; }
		set
		{
			if (bio == value)
				return;
			bio = value;
			RaisePropertyChanged("Biography");
		}
	}
}

创建向导

定义视图模型后,下一步将创建向导。在创建向导之前,我们需要创建一个将托管它的视图模型。此视图模型将订阅向导的 WizardFinished 事件,以便可以处理向导中的数据。对于第一个示例,托管视图模型将具有以下定义

public class BasicViewModel : SampleViewModel
{
	private Wizard wizard;
	public BasicViewModel()
	{
		PageTitle = "Basic";
	}

	public Wizard Wizard
	{
		get
		{
			if (wizard == null)
				initWizard();
			return wizard;
		}
	}
	private void initWizard()
	{
		wizard = new Wizard();
		wizard.Title = "Basic Wizard Title";
		wizard.AddStep("First Title", new FirstPageViewModel());
		wizard.AddStep("Second Title", new SecondPageViewModel());
		wizard.AddStep("Third Title", new ThirdPageViewModel());
		wizard.WizardCanceled += new EventHandler(wizard_WizardCanceled);
		wizard.WizardFinished += new EventHandler(wizard_WizardFinished);
	}
	private void wizard_WizardFinished(object sender, EventArgs e)
	{
		//the wizard is finished. retrieve the fields
		string fname = ((FirstPageViewModel)wizard.Steps[0].Content).FirstName;
		string lname = ((FirstPageViewModel)wizard.Steps[0].Content).LastName;
		//etc...
		//you have to know the wizard structure
		Debug.WriteLine(string.Format("Wizard completed for {0} {1}",
				fname, lname));
	}

	private void wizard_WizardCanceled(object sender, EventArgs e)
	{
		//handle the cancellation
		Debug.WriteLine("The wizard has been canceled");
	}
}

BasicViewModel 类通过 Wizard 属性公开向导。initWizard() 方法初始化向导并订阅向导事件。下一步是定义数据模板。

创建视图

现在向导已经定义,我们需要定义向导呈现其数据的方式。这将通过使用一些数据模板来完成。我们需要为向导本身、向导步骤和将要呈现的内容定义数据模板。

第一步的数据模板可以在下面的列表中看到

<DataTemplate x:Key="FirstPageViewModel">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="Auto"/>
		</Grid.RowDefinitions>
		<Grid.ColumnDefinitions>
			<ColumnDefinition Width="Auto"/>
			<ColumnDefinition/>
		</Grid.ColumnDefinitions>
		<TextBlock Text="First Name" VerticalAlignment="Center"/>
		<TextBlock Text="Last Name" Grid.Row="1" VerticalAlignment="Center"/>
		<TextBox Text="{Binding FirstName, Mode=TwoWay}"
				 Grid.Column="1"/>
		<TextBox Text="{Binding LastName, Mode=TwoWay}"
				 Grid.Column="1" Grid.Row="1"/>
	</Grid>
</DataTemplate>

这是一个非常简单的模板。它使用两个 textbox 控件从用户那里获取姓名输入。第二步的数据模板可以在下面的列表中看到

<DataTemplate x:Key="SecondPageViewModel">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="Auto"/>
		</Grid.RowDefinitions>
		<Grid.ColumnDefinitions>
			<ColumnDefinition Width="Auto"/>
			<ColumnDefinition/>
		</Grid.ColumnDefinitions>
		<TextBlock Text="Email" VerticalAlignment="Center"/>
		<TextBlock Text="Address" Grid.Row="1" Grid.ColumnSpan="2"/>
		<TextBox Text="{Binding Email, Mode=TwoWay}"
				 Grid.Column="1"/>
		<Grid Grid.Row="2" Grid.ColumnSpan="2">
			<v:DynamicContentControl Content="{Binding Address}"
					VerticalContentAlignment="Stretch"
					HorizontalContentAlignment="Stretch"/>
		</Grid>
	</Grid>
</DataTemplate>

此模板使用一个 textbox 获取电子邮件。为了给用户提供输入 address 数据的选项,模板使用 DynamicContentControl 来显示 address 模板。这个 DynamicContentControl 是一个自定义内容控件,它会在其内容更改时更改其数据模板。我稍后会对此进行详细说明。

地址的数据模板可以在下面看到。这将显示在 DynamicContentControl 中。

<DataTemplate x:Key="AddressViewModel">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="Auto"/>
		</Grid.RowDefinitions>
		<Grid.ColumnDefinitions>
			<ColumnDefinition Width="Auto"/>
			<ColumnDefinition/>
		</Grid.ColumnDefinitions>
		<TextBlock Text="Street" Grid.Row="0" VerticalAlignment="Center"/>
		<TextBlock Text="City" Grid.Row="1" VerticalAlignment="Center"/>
		<TextBox Text="{Binding Path=Street, Mode=TwoWay}"
				 Grid.Column="1" Grid.Row="0"/>
		<TextBox Text="{Binding Path=City, Mode=TwoWay}"
				 Grid.Column="1" Grid.Row="1"/>
	</Grid>
</DataTemplate>

第三步的数据模板可以在下面看到

<DataTemplate x:Key="ThirdPageViewModel">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="*"/>
		</Grid.RowDefinitions>
		<TextBlock Text="Biograpgy"/>
		<ScrollViewer Grid.Row="1">
			<TextBox Text="{Binding Biography, Mode=TwoWay}"
				TextWrapping="Wrap" VerticalAlignment="Stretch" />
		</ScrollViewer>
	</Grid>
</DataTemplate>

向导步骤的数据模板将显示向导步骤标题,并在其下方显示特定步骤的内容。这也将通过 DynamicContentControl 实现。向导步骤的数据模板可以在下面看到

<DataTemplate x:Key="WizardStep">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="*"/>
		</Grid.RowDefinitions>
		<TextBlock Text="{Binding Converter={StaticResource conv2}}"
				Style="{StaticResource PhoneTextTitle2Style}"/>
		<v:DynamicContentControl Content="{Binding Content}"
					Margin="5,20,0,5" Grid.Row="1"
					VerticalContentAlignment="Stretch"
					HorizontalContentAlignment="Stretch"/>
	</Grid>
</DataTemplate>

要定义的最后一个视图是向导的视图。向导的视图将是一个 UserControl。此 UserControlDataContext 属性将设置为向导。我们需要一种方法来从这个用户控件触发向导导航。我选择的解决方案是在所需方法上实现单击事件处理程序。此用户控件的 XAML 可以在下面的列表中看到

<Grid x:Name="LayoutRoot" Background="{StaticResource PhoneChromeBrush}">
	<Grid.RowDefinitions>
		<RowDefinition Height="Auto"/>
		<RowDefinition Height="*"/>
		<RowDefinition Height="Auto"/>
	</Grid.RowDefinitions>
	<TextBlock Text="{Binding Path=Title}" Margin="-3,-8,0,0"
				Style="{StaticResource PhoneTextTitle1Style}"/>
	<v:DynamicContentControl Content="{Binding Path=CurrentStep}"
				VerticalContentAlignment="Stretch"
				HorizontalContentAlignment="Stretch" Grid.Row="1"/>
	<StackPanel Orientation="Horizontal" Grid.Row="2" HorizontalAlignment="Center">
		<Button Content="Previous"
		Visibility="{Binding Path=CanGoBack, Converter={StaticResource conv}}"
				Click="btnPrevious_Click"
				>

		</Button>
		<Button Content="Next" Visibility="{Binding Path=CanGoForward,
				Converter={StaticResource conv}}"
				Click="btnNext_Click"
				>

		</Button>
		<Button Content="Finish" Visibility="{Binding Path=CanFinish,
				Converter={StaticResource conv}}"
				Click="btnFinish_Click"
				>

		</Button>
		<Button Content="Cancel" Visibility="{Binding Path=CanCancel,
				Converter={StaticResource conv}}"
				Click="btnCancel_Click"
				>

		</Button>
	</StackPanel>
</Grid>

我知道你们中的许多人会说这不是 MVVM 的方式,但是代码隐藏中的处理程序不包含任何业务逻辑。它们只是委托给视图模型方法。这可以在下面的列表中看到

void BasicWizardView_Loaded(object sender, RoutedEventArgs e)
{
	wizard = DataContext as Wizard;
}

private void btnPrevious_Click(object sender, RoutedEventArgs e)
{
	if (wizard != null)
		wizard.OnPrevious();
}

private void btnNext_Click(object sender, RoutedEventArgs e)
{
	if (wizard != null)
		wizard.OnNext();
}

private void btnFinish_Click(object sender, RoutedEventArgs e)
{
	if (wizard != null)
		wizard.OnFinish();
}

private void btnCancel_Click(object sender, RoutedEventArgs e)
{
	if (wizard != null)
		wizard.OnCancel();
}

MVVM 中没有任何规定不能在代码隐藏中编写任何代码。既然文件存在,那它一定有存在的理由。我认为在使用基本 Wizard 类时,这是一个可接受的选项。在本文后面,我将通过从 Wizard 类派生来更改这一点。这将允许我向派生类添加命令,并从代码隐藏中删除方法调用。

DynamicContentControl 控件

您可能已经注意到,上面描述的一些数据模板使用自定义内容控件来呈现向导及其步骤。此自定义控件的定义可以在下面的列表中看到

public class DynamicContentControl:ContentControl
{
	protected override void OnContentChanged(object oldContent, object newContent)
	{
		base.OnContentChanged(oldContent, newContent);
		//if the new content is null don't set any template
		if (newContent == null)
			return;
		//override the existing template with a template for
		//the corresponding new content
		Type t = newContent.GetType();
		DataTemplate template = App.Current.Resources[t.Name] as DataTemplate;
		ContentTemplate = template;
	}
}

DynamicContentControl 类派生自 ContentControl 并覆盖 OnContentChanged 方法。在覆盖中,控件根据当前内容的类型更改当前内容模板。这在 WP7 中尝试在同一控件中显示不同的数据结构时是必要的,因为没有像 WPF (和 SL5) 那样根据类型动态应用数据模板的功能。我写过一篇关于这个的文章。如果您想了解更多详细信息,可以在此处阅读该文章。

将向导绑定到页面上的控件

定义数据模板后,我们需要做的最后一件事是将向导集成到页面中。为此,我创建了一个视图模型定位器,并在 App.xaml 文件中添加了一个它的实例。定位器的相关属性可以在下面看到

public static BasicViewModel Basic
{
	get
	{
		basic = new BasicViewModel();
		return basic;
	}
}

现在我们需要将页面的数据上下文绑定到此属性。这可以在下面的行中看到

DataContext="{Binding Path=Wizard, Source={StaticResource Locator}}"

最后要做的事情是添加将显示向导的控件。这将通过使用 BasicWizardView 完成,如下所示

<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
	<loc:BasicWizardView DataContext="{Binding Wizard}"
				HorizontalContentAlignment="Stretch"
				VerticalContentAlignment="Stretch"/>
</Grid>

在这个视图中,我还订阅了 BackKeyPress 事件,以便将设备返回按钮与向导集成。这个处理程序也委托给视图模型方法,所以目前它是一个可接受的解决方案。代码如下所示

private void PhoneApplicationPage_BackKeyPress
	(object sender, System.ComponentModel.CancelEventArgs e)
{
	BasicViewModel vm = DataContext as BasicViewModel;
	if (vm != null && vm.Wizard != null)
	{
		if (vm.Wizard.CanPrevious)
		{
			vm.Wizard.Previous();
			e.Cancel = true;
		}
	}
}

运行第一个示例的结果可以在下面的图片中看到

正如您从上图中看到的,只有在第二步中输入有效数据后,我们才能进入第三步。

基本用法(Caliburn)

在 Caliburn 应用程序中集成向导的方式几乎相同。为了符合命名约定,我将从 Wizard 类派生,但不会添加任何代码。新的向导将如下所示

public class WizardViewModel:Wizard
{} 

这个向导的视图是一个名为 WizardViewUserControl,如下所示

<TextBlock Text="{Binding Path=Title}" Margin="-3,-8,0,0"
	Style="{StaticResource PhoneTextTitle1Style}"/>
<v:DynamicContentControl Content="{Binding Path=CurrentStep}"
			 VerticalContentAlignment="Stretch"
			 HorizontalContentAlignment="Stretch" Grid.Row="1"/>
<StackPanel Orientation="Horizontal" Grid.Row="2" HorizontalAlignment="Center">
	<Button Content="Previous" x:Name="Previous"
			Visibility="{Binding Path=CanPrevious,
				Converter={StaticResource conv}}" >

	</Button>
	<Button Content="Next" x:Name="Next"
			Visibility="{Binding Path=CanNext,
				Converter={StaticResource conv}}">

	</Button>
	<Button Content="Finish" x:Name="Finish"
			Visibility="{Binding Path=CanFinish,
				Converter={StaticResource conv}}">

	</Button>
	<Button Content="Cancel" x:Name="Cancel"
			Visibility="{Binding Path=CanCancel,
				Converter={StaticResource conv}}">
	</Button>
</StackPanel>

在 Caliburn Micro 中,不再需要向代码隐藏中添加代码来触发导航,因为框架会自动将 viewmodel 中相应的方法和属性绑定到视图中命名合适的元素。为了实现这个功能,我向引导程序类添加了以下几行

container.RegisterPerRequest(typeof(BasicSampleViewModel),
	"BasicSampleViewModel", typeof(BasicSampleViewModel));
container.RegisterPerRequest(typeof(WizardViewModel),
	"WizardViewModel", typeof(WizardViewModel));

BasicSampleViewModel 是托管向导的 viewmodel。它实例化向导并订阅其 WizardFinishedWizardCancelled 事件。为了将此向导集成到页面中,我使用了如下所示的简单内容控件

<ContentControl x:Name="Wizard"
	HorizontalContentAlignment="Stretch"
	 VerticalContentAlignment="Stretch"
	/>

从 Wizard 类派生

以当前状态使用向导是可以的,但用户可能希望封装一些逻辑,甚至添加一些属性。为了实现这一点,我们可以从向导控件派生以添加所需的功能。对于第二个使用示例,我将创建一个向导派生类。此类的将封装步骤的创建,并将公开更多属性。这些新的向导属性将公开每个向导步骤中的属性。这样做是为了在读取 WizardFinished 事件中的属性时提高可用性和类型安全。

这个新派生类的定义可以在下面的列表中看到

public class DerivedWizard:Wizard
{
	private RelayCommand<object> nextCmd, prevCmd, finishCmd, cancelCmd;

	public DerivedWizard()
	{
		Title = "Derived Wizard Title";
		//create the wizard steps
		AddStep("First Step", new FirstPageViewModel());
		AddStep("Second Step", new SecondPageViewModel());
		AddStep("Third Step", new ThirdPageViewModel());
	}

	public string FirstName
	{
		get { return ((FirstPageViewModel)Steps[0].Content).FirstName; }
	}
	public string LastName
	{
		get { return ((FirstPageViewModel)Steps[0].Content).LastName; }
	}
	public string Email
	{
		get { return ((SecondPageViewModel)Steps[1].Content).Email; }
	}
	public AddressViewModel Address
	{
		get { return ((SecondPageViewModel)Steps[1].Content).Address; }
	}
	public string Biography
	{
		get { return ((ThirdPageViewModel)Steps[1].Content).Biography; }
	}

	public RelayCommand<object> NextCommand
	{
		get
		{
			if (nextCmd == null)
				nextCmd = new RelayCommand<object>
				(param => Next(), param => CanNext);
			return nextCmd;
		}
	}
	public RelayCommand<object> PreviousCommand
	{
		get
		{
			if (prevCmd == null)
				prevCmd = new RelayCommand<object>
				(param => OnPrevious(param), param => CanPrevious);
			return prevCmd;
		}
	}
	public RelayCommand<object> CancelCommand
	{
		get
		{
			if (cancelCmd == null)
				cancelCmd = new RelayCommand<object>
				(param => Cancel(), param => CanCancel);
			return cancelCmd;
		}
	}
	public RelayCommand<object> FinishCommand
	{
		get
		{
			if (finishCmd == null)
				finishCmd = new RelayCommand<object>
				(param => Finish(), param => CanFinish);
			return finishCmd;
		}
	}

	public void OnPrevious(object param)
	{
		CancelEventArgs args = param as CancelEventArgs;
		if (args != null && CanPrevious)
		{
			Previous();
			args.Cancel = true;
		}
	}
}

正如您从上面的代码中看到的,我还添加了将用于触发导航的命令。此代码中另一个重要的事情是 OnPrevious 方法。这将被用于处理返回按钮的集成。

OnPrevious 方法检查参数以查看它是否是 CancelEventArgs 类的实例。如果是,并且如果向导不在第一步,该方法将调用 Previous 导航方法以导航到上一步,并将 Cancel 属性设置为 false 以取消事件。如果向导在第一步,该方法将不执行任何操作,按下返回按钮将退出向导页面。

为了使用这个新向导,我们需要在定位器类中添加一个新属性,该属性将公开此新类型的一个实例,然后我们将其绑定到控件。此属性可以在下面看到

//per cal instance
public static DerivedViewModel Derived
{
	get
	{
		derived = new DerivedViewModel();
		return derived;
	}
}

现在我添加了命令,我可以删除代码隐藏中的逻辑。新的向导视图如下所示

<DataTemplate x:Key="DerivedWizard">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="Auto"/>
			<RowDefinition Height="*"/>
			<RowDefinition Height="Auto"/>
		</Grid.RowDefinitions>
		<TextBlock Text="{Binding Path=Title}" Margin="-3,-8,0,0"
				Style="{StaticResource PhoneTextTitle1Style}"/>
		<v:DynamicContentControl Content="{Binding Path=CurrentStep}"
				VerticalContentAlignment="Stretch"
				HorizontalContentAlignment="Stretch" Grid.Row="1"/>
		<StackPanel Orientation="Horizontal" Grid.Row="2"
				HorizontalAlignment="Center">
			<Button Content="Previous"
			Visibility="{Binding Path=CanPrevious,
			Converter={StaticResource conv}}" >
				<i:Interaction.Triggers>
					<i:EventTrigger EventName="Click">
						<cmd:EventToCommand Command=
						"{Binding PreviousCommand}" />
					</i:EventTrigger>
				</i:Interaction.Triggers>
			</Button>
			<Button Content="Next" Visibility="{Binding Path=CanNext,
					Converter={StaticResource conv}}">
				<i:Interaction.Triggers>
					<i:EventTrigger EventName="Click">
					    <cmd:EventToCommand
					    Command="{Binding NextCommand}" />
					</i:EventTrigger>
				</i:Interaction.Triggers>
			</Button>
			<Button Content="Finish"
				Visibility="{Binding Path=CanFinish,
				Converter={StaticResource conv}}">
				<i:Interaction.Triggers>
					<i:EventTrigger EventName="Click">
						<cmd:EventToCommand Command=
						"{Binding FinishCommand}" />
					</i:EventTrigger>
				</i:Interaction.Triggers>
			</Button>
			<Button Content="Cancel"
				Visibility="{Binding Path=CanCancel,
					Converter={StaticResource conv}}">
				<i:Interaction.Triggers>
					<i:EventTrigger EventName="Click">
						<cmd:EventToCommand Command=
						"{Binding CancelCommand}" />
					</i:EventTrigger>
				</i:Interaction.Triggers>
			</Button>
		</StackPanel>
	</Grid>
</DataTemplate>

如您所见,导航现在通过 EventToCommand ActionTrigger 完成。后退按钮集成以相同的方式完成。代码如下所示

<i:Interaction.Triggers>
	<i:EventTrigger EventName="BackKeyPress">
		<cmd:EventToCommand Command="{Binding Path=Wizard.PreviousCommand}"
					PassEventArgsToCommand="True"/>
	</i:EventTrigger>
</i:Interaction.Triggers>

高级用法(测试应用程序讨论)

大多数情况下,向导的结构在编译时是已知的。这包括步骤的数量以及每个步骤将向用户呈现的数据。然而,在某些情况下并非如此。在某些情况下,步骤的数量和每个步骤中呈现的数据在运行时之前都是未知的。例如,在测试应用程序中就会发生这种情况。在这类应用程序中,用户需要一次回答一个问题。在尝试构建此类测试应用程序时,向导是一个不错的选择。在这种情况下,步骤的数量和每个步骤中的内容都只在运行时,即用户开始测试时才可知。

对于高级用法场景,我将讨论如何在本文中使用向导类来构建测试应用程序。上一篇文章版本中的高级用法示例还可以,但有点抽象。我认为需要一个更具体的场景来更好地展示您可以使用这些类做什么。如果您仍然想阅读以前的版本,可以从页面顶部的菜单中选择它。

最近,我不得不实现一个测试应用程序。我仔细思考了如何向用户呈现每个问题以及如何让他从一个问题导航到另一个问题。然后我想到了:以向导的方式一次呈现一个问题,并使用向导类来完成此操作。尽管测试应用程序是在 WPF 中实现的,但我认为讨论仍然有意义。这些向导类非常灵活。虽然它们是为 WP7 编写的,但这些类也可以在 WPF 和 Silverlight 项目中使用,而无需任何额外的修改。

在用户测试开始之前,应用程序需要从服务器检索测试数据。这些数据将包括问题数量、测试分配的总时间以及其他特定测试属性(但不包括问题数据)。这些数据将用于初始化向导。实现此功能的假设代码如下所示

public class TestWizard:Wizard
{
	//...
	Test currentTest = null; 
	public TestWizard(Test test) 
	{
		currentTest = test; 
		//add the wizard steps 
		//the content for each step will be null at first
		for (int i = 0;i< currentTest.NumberOfQuestions;i++) 
			Steps.Add(new WizardStep()); 
		}
	} 
	//...
}//end of class definition

//in another ViewModel	
Test theTest = TestService.GetTest();
Workspace = new TestWizard(theTest); 
//...

向导初始化后,测试需要开始。我们将通过调用假设的 StartTest() 方法来完成此操作。此方法将在派生 TestWizard 类中实现,以及用于导航的命令。这可以在下面看到

//...
public void StartTest()
{
	IsBusy = true;
	//get the first question from the server
	//the method returns a TestQuestion derived class            
	TestQuestion tq = TestService.GetQuestion(currentTest.Id,1);
	//the wizard is already at the first question
	//just fill the content and the auto DataTemplates
	//will do the rest
	CurrentStep.Content = tq;
	IsBusy = false;
}
//...

现在是激动人心的部分。一个特定的测试可能包含很多问题。这意味着如果我们决定一次性获取所有问题,就需要从服务器检索大量数据。解决此问题的一个方法是一次检索一个问题。除了检索少量数据外,此选项还提供其他优势。其中之一是,当用户移动到下一个问题时,每个问题的答案都可以保存。在桌面电脑上,此方法最大的优势是,如果在测试期间断电,您可以保留您的答案。当电源恢复时,您可以从中断处继续。这正是上面所示的假设代码所做的。它只检索第一个测试问题。

要导航到第二个问题(依此类推),用户必须按下“下一步”按钮。当他/她这样做时,向导应将当前问题保存到服务器并检索新问题。这可以通过覆盖 Next() 方法来实现。这可以在下面的假设代码中看到

//...
public override void Next()
{
	IsBusy = true;
	//save the current question
	TestQuestion tq = (TestQuestion)CurrentStep.Content;
	TestService.SaveQuestionAnswer(tq);
	//get the next question
	tq = TestService.GetQuestion(currentTest.Id, ++tq.QuestionNumber);
	//set the content for the next step
	//and navigate
	int idx = Steps.IndexOf(CurrentStep);
	Steps[idx + 1].Content = tq;
	IsBusy = false;
	base.Next();
}
//...

如您所见,当向导从服务器获取数据时,IsBusy 标志被设置。数据检索后,设置下一步的内容,向导使用基类的 Next() 实现进行导航。

当用户想回到上一个问题时,同样的事情也会发生。当前问题被保存,然后检索并显示上一个问题。这可以在下面的假设代码中看到

//...
public override void Previous()
{
	IsBusy = true;
	//save the current question
	TestQuestion tq = (TestQuestion)CurrentStep.Content;
	TestService.SaveQuestionAnswer(tq);
	//get the previous question
	tq = TestService.GetQuestion(currentTest.Id, --tq.QuestionNumber);
	//set the content for the previous step
	//and navigate
	int idx = Steps.IndexOf(CurrentStep);
	Steps[idx - 1].Content = tq;
	IsBusy = false;
	base.Previous();
}
//...

我想就是这样了。我希望您会发现此向导实现有用。如果您喜欢这篇文章并且认为此代码对您的应用程序有用,请花一分钟时间**投票和评论**。另外,如果您认为我应该更改某些内容,请告诉我。

历史

  • 2011年7月1日星期五 - 初次发布
  • 2011年7月5日星期二 - 文章更新
  • 2011年7月11日星期一 - 文章更新
  • 2011年8月4日星期四 - 文章更新
© . All rights reserved.