适用于 Windows Mobile 的基于 TreeView 的选项组件
这是一个适用于 Windows Mobile 的基于 TreeView 的选项组件
问题陈述
许多 Windows Mobile 应用程序使用“选项”或“设置”对话框来管理应用程序级别的设置。该对话框通常包含一个选项卡控件,每个选项卡上都有一个或多个常规控件,例如标签、复选框、单选按钮、下拉列表、微调器、文本框等。以下屏幕截图显示了一些示例。
设计此类对话框时,我们需要仔细考虑要使用哪种类型的控件,每个选项卡上有多少控件,以及如何布局这些控件,使其在不同的屏幕分辨率、尺寸和方向(纵向和横向)下都能美观且易于操作。如果需要弹出软键盘来收集用户输入,布局问题将变得更加棘手。处理布局问题的一种方法是设计成适应最低公分母——最低分辨率和最小屏幕尺寸。但很多时候,我们会发现不得不使用更多的选项卡和更少的控件,这会浪费宝贵的屏幕空间,尤其是当用户恰好拥有更高分辨率和/或更大屏幕尺寸的设备时。以下来自 480x800 WVGA 设备的屏幕截图说明了这一点。
由“选项”对话框管理的应用程序设置需要持久化到数据存储中。在完整的 .NET Framework 中,这通常通过“设置”文件完成,它对应于 `app.config` 文件中的 `<userSettings>` 部分。在设计时,`Settings` 文件用于生成一个 `Settings` 类(继承自 `System.Configuration.ApplicationSettingsBase`),该类为每个设置提供了强类型访问器,并能够将设置保存到用户特定的文件 (`user.config`) 中。不幸的是,`.NET Compact Framework` 中不存在 `Settings` 文件机制。我们必须将设置存储在注册表中,这容易出现部署和安全问题,或者自行设计存储设置的方法,就像 pbrooks 在 Compact Framework 的 AppSettings 实现以及 Shawn Miller 在 .NET Compact Framework ConfigurationManager 中所做的那样。两者都使用了类似于 `app.config` 文件 `<appSettings>` 部分的 XML 文件。他们将应用程序设置视为“平面”的键值对,没有关于设置如何组织结构的任何支持。
基于 TreeView 的选项组件
在本文中,我建议使用 `TreeView` 控件结合 XML 文件来管理应用程序设置。以下屏幕截图展示了该组件的实际运行效果。
从 UI 的角度来看,基于 `TreeView` 的选项组件具有以下优点:
- 不再有布局方面的烦恼。无论我们有多少选项,使用何种屏幕分辨率、尺寸和方向,它都能很好地工作。
- 选项可以组织成组、子组等。
- 直观且便于游戏杆操作(用户可以用一只手操作)。
- 上下移动游戏杆以上下移动选择光标(灰色)。
- 向右移动游戏杆以展开节点。
- 向左移动游戏杆以折叠节点。
- 当当前节点是值节点时,按下游戏杆以选择它。
- 当当前节点是折叠的组或选项节点时,按下游戏杆以展开它。
- 当当前节点是展开的组或选项节点时,按下游戏杆以折叠它。
- 选定的值会被高亮显示(黄色)。
- 高效利用屏幕空间。无需翻阅不同的选项卡或屏幕即可找到正确的选项。
- 所有选项都采用一致的多项选择式操作。用户无需学习不同且有时令人困惑的用户界面。
使用该组件的唯一要求是,每个选项或设置必须有一个从一组离散值中选择的值,或者类似于多项选择。这并不算太受限制。UI 控件,如单选按钮、下拉列表、微调器和复选框,实际上是多项选择类型的控件。文本框,通常用于连续值选项,如距离、重量、缓存大小等,应不惜一切代价避免使用,因为在移动设备上输入文本框非常麻烦。因此,在处理连续值选项时,我们应该想办法将其离散化。在我们的移动应用程序的特定上下文中,它是否可以用一组代表性值来合理地描述?例如,是否有可能将其分解为几个范围?所有选项离散化的一个额外好处是:我们不再需要输入验证,因为所有值都经过仔细选择且有效。
用于填充 `TreeView` 的数据存储在 XML 文件中。特定的 XML 文件除了根节点外,只有三种节点类型:`group`、`option` 和 `value`。下面的示例展示了一个 XML 文件:
<?xml version="1.0" encoding="utf-8" ?>
<options>
<group name="General">
<option name="TimeZone" displayName="Time zone">
<value name="EST" selected="true" />
<value name="CST" selected="false" />
<value name="MST" selected="false" />
<value name="PST" selected="false" />
</option>
<option name="UpdateInterval" displayName="Update interval">
<value name="10" displayName="10 sec" selected="false" />
<value name="30" displayName="30 sec" selected="true" />
<value name="60" displayName="1 min" selected="false" />
<value name="300" displayName="5 min" selected="false" />
<value name="600" displayName="10 min" selected="false" />
</option>
<option name="CacheSize" displayName="Cache size">
<value name="32" displayName="32 MB" selected="true" />
<value name="64" displayName="64 MB" selected="false" />
<value name="128" displayName="128 MB" selected="false" />
</option>
<option name="CheckInterval" displayName="Check for app update">
<value name="0" displayName="Every time app starts" selected="true" />
<value name="1" displayName="Every day" selected="false" />
<value name="7" displayName="Every week" selected="false" />
<value name="30" displayName="Every month" selected="false" />
<value name="365" displayName="Every year" selected="false" />
</option>
</group>
<group name="Appearance">
<option name="Skin">
<value name="Classic" selected="true" />
<value name="IceFusion" displayName="Ice Fusion" selected="false" />
<value name="Monochrome" selected="false" />
</option>
<option name="ShowToolbar" displayName="Show toolbar">
<value name="true" selected="true" />
<value name="false" selected="false" />
</option>
<option name="ShowStatusBar" displayName="Show status bar">
<value name="true" selected="true" />
<value name="false" selected="false" />
</option>
</group>
<group name="SecurityPrivacy" displayName="Security & Privacy">
<option name="EnablePassword" displayName="Enable password protection">
<value name="true" selected="true" />
<value name="false" selected="false" />
</option>
<group name="SharedContents" displayName="Shared contents">
<option name="ContactInfo" displayName="Contact info">
<value name="true" selected="false" />
<value name="false" selected="true" />
</option>
<option name="Photos">
<value name="true" selected="true" />
<value name="false" selected="false" />
</option>
<option name="Posts">
<value name="true" selected="true" />
<value name="false" selected="false" />
</option>
</group>
</group>
</options>
关于此 XML 有一些规则:
- 每种节点类型都有一个 `name` 和一个 `displayName` 属性。`name` 是必需的,用于或引用在我们的程序中。`displayName` 是可选的,用于在 `TreeView` 中显示。如果缺少 `displayName`,则使用相应的 `name` 进行显示。
- `group` 可以包含零个或多个 `option` 节点和 `group` 节点(或子组)。我们可以任意嵌套 `group` 节点级别。
- `option` 必须只包含一个或多个 `value` 节点。
- 多个 `value` 节点中只有一个可以具有 `selected` 属性为 "`true`",表示它是该 `option` 的当前 `value`。
从编程的角度来看,使用这样的 XML 作为我们选项组件的数据存储具有以下优点:
- XML 和 `TreeView` 都基于树形结构。一个用于存储,一个用于显示。这种完美的匹配使编程变得容易。
- 按组和子组组织选项是内在支持的。
- 它使得管理选定值和所有可用值变得容易。使用传统的设置文件,只有选定值会被持久化,而可用值要么在单独的资源文件中,要么在程序中硬编码。
- 在某种程度上,我们可以更改选项而无需重新编译我们的程序。也可以远程更新 XML。
- 它简化了本地化。只需将所有 `displayName` 属性翻译成不同的语言。
实现
为了最大的灵活性,我没有将选项“组件”制作成用户控件或类库。它只是一个简单的类,您可以将其复制并粘贴到您的项目中。
public class OptionsManager
{
private const string optionValueSeparator = ": ";
private static XmlDocument xdoc = null;
private static string theFile = null;
private static Color selectedValueBackColor = Color.Yellow;
private static bool isChanged = false; //is there any difference between xdoc and theFile
static OptionsManager()
{
theFile = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().
GetName().CodeBase) + @"\Options.xml";
xdoc = new XmlDocument();
if (File.Exists(theFile))
xdoc.Load(theFile);
else
{
LoadDefault();
Save(); //create theFile
}
}
//load default options from embeded resource
public static void LoadDefault()
{
using (StreamReader sr =new StreamReader(Assembly.GetExecutingAssembly().
GetManifestResourceStream("TreeviewOptions.Options.Options.xml")))
{
xdoc.LoadXml(sr.ReadToEnd());
sr.Close();
isChanged = true;
}
}
//Persist xdoc to theFile
public static void Save()
{
if (isChanged)
{
xdoc.Save(theFile);
isChanged = false;
}
}
//Cancel changes to xdoc (DOM) by reloading it from theFile
public static void Cancel()
{
if (isChanged)
{
xdoc.Load(theFile);
isChanged = false;
}
}
//Load xdoc to the specified TreeView
public static void LoadToTreeView(TreeView tvw)
{
tvw.Nodes.Clear();
XmlNode root = xdoc.DocumentElement;
DoLoading(tvw, root);
}
//treeviewNode can be a TreeView or a TreeNode object
private static void DoLoading(object treeviewNode, XmlNode xmlNode)
{
XmlNodeList xmlSubnodes = xmlNode.ChildNodes;
foreach (XmlNode xsn in xmlSubnodes)
{
NodeType nodeType = GetXmlNodeType(xsn);
if (nodeType == NodeType.Group)
{
string groupDisplayName = GetXmlNodeDisplayName(xsn);
TreeNode tn = null;
if (treeviewNode is TreeView)
{
tn = ((TreeView)treeviewNode).Nodes.Add(groupDisplayName);
tn.Tag = string.Format("/group[@name='{0}']",
((XmlElement)xsn).GetAttribute("name"));
}
else
{
tn = ((TreeNode)treeviewNode).Nodes.Add(groupDisplayName);
tn.Tag = tn.Parent.Tag + string.Format("/group[@name='{0}']",
((XmlElement)xsn).GetAttribute("name"));
}
DoLoading(tn, xsn);
}
else if (nodeType == NodeType.Option)
{
string optionDisplayName = GetXmlNodeDisplayName(xsn);
TreeNode tn = null;
if (treeviewNode is TreeView)
{
tn = ((TreeView)treeviewNode).Nodes.Add(optionDisplayName);
tn.Tag = string.Format("/option[@name='{0}']",
((XmlElement)xsn).GetAttribute("name"));
}
else
{
tn = ((TreeNode)treeviewNode).Nodes.Add(optionDisplayName);
tn.Tag = tn.Parent.Tag + string.Format("/option[@name='{0}']",
((XmlElement)xsn).GetAttribute("name"));
}
XmlNodeList values = xsn.ChildNodes;
string selectedValueName = null;
foreach (XmlNode v in values)
{
string valueDisplayName = GetXmlNodeDisplayName(v);
TreeNode vtn = tn.Nodes.Add(valueDisplayName);
vtn.Tag = tn.Tag + string.Format("/value[@name='{0}']",
((XmlElement)v).GetAttribute("name"));
if (((XmlElement)v).GetAttribute("selected") == "true")
{
vtn.BackColor = selectedValueBackColor;
selectedValueName = valueDisplayName;
}
}
tn.Text += optionValueSeparator + selectedValueName;
}
}
}
private static string GetXmlNodeDisplayName(XmlNode node)
{
string dName = ((XmlElement)node).GetAttribute("displayName");
if (string.IsNullOrEmpty(dName))
dName = ((XmlElement)node).GetAttribute("name");
return dName;
}
//if tn is a value node, update tn.Parent display and corresponding xml value node selection
public static void ChangeValue(TreeNode tn)
{
if (IsValueNode(tn))
{
//update tn.Parent display
string parentDisplayName = Regex.Split(tn.Parent.Text, optionValueSeparator)[0];
tn.Parent.Text = parentDisplayName + optionValueSeparator + tn.Text;
tn.BackColor = selectedValueBackColor;
TreeNodeCollection valueNodes = tn.Parent.Nodes;
foreach (TreeNode vn in valueNodes)
{
vn.BackColor = (vn == tn) ? selectedValueBackColor : Color.Empty;
}
//update xml value node selection
XmlNode option = xdoc.DocumentElement.
SelectSingleNode("/options" + tn.Tag).ParentNode;
XmlNodeList values = option.ChildNodes;
foreach (XmlNode v in values)
((XmlElement)v).SetAttribute("selected", "false");
((XmlElement)xdoc.DocumentElement.SelectSingleNode("/options" + tn.Tag)).
SetAttribute("selected", "true");
isChanged = true;
}
}
//If the XPath stored in tn.Tag ends with a value tag, then tn is a value treenode
public static bool IsValueNode(TreeNode tn)
{
string path = (string)tn.Tag;
string[] parts = path.Split("/".ToCharArray());
return (parts[parts.Length - 1].StartsWith("value"));
}
//optionXPath is the XPath to the option whose value (name attribute)
//is to be retrieve//The root /options can be omitted.
public static string GetOptionValue(string optionXPath)
{
if (!optionXPath.StartsWith("/options"))
optionXPath = "/options" + optionXPath;
XmlNode option = xdoc.DocumentElement.SelectSingleNode(optionXPath);
if (option != null)
{
XmlNodeList values = option.ChildNodes;
foreach (XmlNode v in values)
{
if (((XmlElement)v).GetAttribute("selected") == "true")
return ((XmlElement)v).GetAttribute("name");
}
}
return null;
}
private static NodeType GetXmlNodeType(XmlNode node)
{
switch (node.Name)
{
case "options":
return NodeType.Root;
case "group":
return NodeType.Group;
case "option":
return NodeType.Option;
case "value":
return NodeType.Value;
default:
throw new ApplicationException("Unknow Xml node type.");
}
}
private enum NodeType
{
Root,
Group,
Option,
Value
}
}
关于代码的一些亮点:
首先,我们需要将 XML 文件 `Options.xml` 设为嵌入式资源,方法是在 Visual Studio 中将其“生成操作”属性设置为“嵌入式资源”。正如我们的 `OptionsManager` 类的 `static` 构造函数中所示,在应用程序首次启动时,XML 文件会通过 `LoadDefault` 方法从嵌入式资源加载到内存,并保存到我们的应用程序可执行文件所在的位置。在后续启动时,由于 XML 文件已存在于我们的应用程序根目录,我们只需从中加载。
`LoadToTreeView` 方法将 XML 文档加载到指定的 `TreeView` 控件中。由于我们可以有多层嵌套的 `group` 节点,因此我们需要递归加载,如 `private` 方法 `DoLoading` 所示。对于 `group` 节点,我们在 `TreeView` 中显示其 `displayName`。对于 `option` 节点,我们显示其 `displayName` 和当前选定值的 `displayName`(用“:”分隔)。对于 `value` 节点,我们显示其 `displayName`,如果它当前被选中,我们还会将其背景色设置为 `selectedValueBackColor`。对于每个树节点,我们构建一个指向相应 XML 节点的 `XPath`,并将其存储在树节点的 `Tag` 属性中。之后,我们可以通过这个 `XPath` 轻松地将树节点与 XML 节点匹配。例如,下表显示了某个树节点的 `XPath` 外观:
树节点 | XPath |
General/Time zone option | /group[@name='General']/option[@name='TimeZone'] |
General/Time zone/PST value | /group[@name='General']/option[@name='TimeZone']/value[@name='PST'] |
当我们想更改选项的选定值时,会调用 `ChangeValue` 方法。这通常由按下游戏杆的事件触发。在此方法中,我们需要做两件事。首先,在 `TreeView` 控件中,我们需要更新相应的 `option` 节点的显示,并将高亮标记更改为当前选定的 `value` 节点。其次,在底层 XML 文档中,我们需要更新相应 `option` 的 `value` 节点的 `selected` 属性,以反映当前选定的是哪个值。
给定选项的 `XPath`,`GetOptionValue` 方法会返回该选项的选定值(确切地说是值的 `name` 属性)。由于我们了解 `Options.xml` 文件的详细信息,因此由我们负责将检索到的 `string` 值转换为适当的数据类型。
使用 OptionsManager 类
以下是在 Windows Mobile 项目中使用我们的 `OptionsManager` 类的步骤:
- 创建 `Options.xml` 文件并将其设为嵌入式资源。
提示:使用示例 `Options.xml` 文件作为模板。确保 `LoadDefault` 方法中对 `Options.xml` 文件的命名空间引用正确。 - 将一个 Windows Form `OptionsForm` 添加到项目中。这将是我们的选项对话框。
- 向 `OptionsForm` 添加一个 `TreeView` 控件和以下四个菜单项或按钮:
- 完成 - 保存对选项的更改并关闭选项对话框
- 取消 - 取消对选项的更改并关闭选项对话框
- 默认 - 将选项恢复到其默认值(如嵌入式资源中存储的 `Options.xml` 文件中所指定)
- 更改值 - 将相应选项的值更改为 `TreeView` 选择光标指示的值
- 像这样为 `OptionsForm` 的 `Load` 和 `Closed` 事件处理程序接线:
private void OptionsForm_Load(object sender, EventArgs e) { LoadTreeView(); } private void OptionsForm_Closed(object sender, EventArgs e) { if (this.DialogResult == DialogResult.OK) OptionsManager.Save(); else OptionsManager.Cancel(); } private void LoadTreeView() { OptionsManager.LoadToTreeView(treeView1); //expand the first level TreeNodeCollection nodes = treeView1.Nodes; foreach (TreeNode n in nodes) n.Expand(); }
- 像这样为 `TreeView` 控件的 `KeyPress` 和 `AfterSelect` 事件处理程序接线:
private void treeView1_KeyPress(object sender, KeyPressEventArgs e) { if (OptionsManager.IsValueNode(treeView1.SelectedNode)) { OptionsManager.ChangeValue(treeView1.SelectedNode); } else { if (treeView1.SelectedNode.IsExpanded) treeView1.SelectedNode.Collapse(); else treeView1.SelectedNode.Expand(); } } private void treeView1_AfterSelect(object sender, TreeViewEventArgs e) { //make the Change Value menu item available only when the current tree node is a value node menuMenuChangeValue.Enabled = OptionsManager.IsValueNode(treeView1.SelectedNode); }
- 像这样为四个菜单项或按钮的 `Click` 事件处理程序接线:
private void menuDone_Click(object sender, EventArgs e) { this.DialogResult = DialogResult.OK; } private void menuMenuCancel_Click(object sender, EventArgs e) { this.DialogResult = DialogResult.Cancel; } private void menuMenuDefault_Click(object sender, EventArgs e) { OptionsManager.LoadDefault(); LoadTreeView(); } private void menuMenuChangeValue_Click(object sender, EventArgs e) { OptionsManager.ChangeValue(treeView1.SelectedNode); }
- 在项目的任何地方调用 `GetOptionValue` 方法来获取任何选项的值。例如:
int cacheSize = int.Parse(OptionsManager.GetOptionValue( "/group[@name='General']/option[@name='CacheSize']")); bool sharePhotos = bool.Parse(OptionsManager.GetOptionValue( "/group[@name='SecurityPrivacy']/group[@name='SharedContents']/option[@name='Photos']"));
进一步改进
“选项”组件可以在以下方面进一步改进:
- 使用 XSD 架构验证 `Options.xml`。
- 通过 `ImageList` 控件为树节点添加图标。
- 允许同一级别的组或选项节点一次只展开一个。对于小屏幕尺寸,这种模式是可取的。
结论
提出并实现了一个基于 `TreeView` 的 Windows Mobile 选项组件。与传统的选项对话框设计方式相比,它具有几个优点。这些优点包括消除了布局问题,高效利用屏幕空间,直观一致的用户界面,以及在持久化和组织应用程序设置方面良好的结构支持。
您可以在 此处下载源代码。