1. 需求
物联网项目中要集成大量的设备,作为一种简单的数字孪生手段,每一型号的设备都需要一个对应的虚拟实现,也就是用界面把这个设备呈现出来。设备有多个可管理的"属性",对这个设备的监测对应获取这个设备"属性"的值,对这个设备的控制对应改变某些"属性"的值。
非常多的设备都具备"开关(Switch)"这个属性,开关可以是电源开关(Power Switch),双态开关(Enable/Disable)等,对这个开关清零("0"),一般表示关闭这个开关,设备的某些功能处于禁用状态;对这个开关置1,一般表示打开这个开关,设备的对应功能处于启用状态。
对于单一的,简单的 Form,只需要几个 Label + Switch 控件就可以实现"开关"的功能了。但是这种直白的控件组合无法满足哪怕是稍微复杂一些的设备的虚拟化要求。这些设备除了开关属性以外,往往还具备其它属性,把这些属性都以"散装"的原生控件堆积在一起,XAML 文件和 XAML.cs 都会非常巨大。每一个原生控件在同一个页面中都是"本地全局"变量,不论从命名规则还是从赋值上都存在错误风险。不言而喻,这种出错风险随着控件的数量是呈指数增长的。因此,绝对有必要将一些特定的属性进行封装,只要对其进行完整的严格的测试就可以放心使用了。
这种封装是必须的,必要的,是面向对象编程的基本要求。
现在我们就利用 Xamarin.Forms 来构建一个 "开关" 类型的控件。
2. 实现
我已经构建好了I2oT应用项目,编码在 I2oT,发布在 I2oT.Android 和 I2oT.IOS,看Android部分。下图是按照步骤 2.1/2.2 执行完成后的自定义控件文件结构,图中已经定义了7个自定义控件,这里以 BoolProperty 为例说明怎么来构建一个"开关"控件。
2.1 为自定义控件创建一个命名空间
在 I2oT 项目中,新建文件夹 UserControls。按照 Visual Studio 的命名规则,新建文件夹后,在这个文件夹下所生成的类都会归类到 I2oT.UserControls 命名空间里。
2.2 为自定义控件创建视图和代码文件
右键点击 "UserControls" 文件夹,选择"添加➡新建项➡Xamarin.Forms➡内容视图(ContentView)➡输入BoolProperty",在 UserControls 文件夹创建一个 BoolProperty 视图。之所以以 BoolProperty 命名,是因为开关量就其数据本质而言就是布尔类型的。
2.3 构造视图
双击 BoolProperty.xaml 文件,将模板原文替换成以下内容。
XML
<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Name="this"
x:Class="I2oT.UserControls.BoolProperty">
<ContentView.Content>
<StackLayout Margin="0"
Orientation="Horizontal"
VerticalOptions="StartAndExpand"
HorizontalOptions="FillAndExpand">
<Label x:Name="lvCaption" Text="Switch" Style="{StaticResource PropertyCaptionStyle}"/>
<Switch x:Name="vControl" ThumbColor="Green"
HorizontalOptions="StartAndExpand"
Toggled="OnSwitchToggled"/>
<Label x:Name="lvComment" Text="ON/OFF" Style="{StaticResource PropertyCommentStyle}"/>
</StackLayout>
</ContentView.Content>
</ContentView>
在这个描述文件中,定义了三个控件:
- 一个 Label 控件,取名为lvCaption,用以显示这个属性的名称,例如,应用中可能会有"电源开关","是否启用","通断"等名称。
- 一个 Switch 控件,取名为vControl,用以呈现这个布尔对象的状态,IsToggle 属性可以表示 True/False,对应这个开关的开/关,通/断状态。
- 一个 Label 控件, 取名为lvComment,用以说明这个状态的当前值,以避免使用者的困扰。
这三个控件以水平方向封装到一个 StackLayout 中,在被调用时,StackLayout 作为一个整体显示,而内部的三个控件的相对位置不会发生紊乱。
下一步讨论前,展开 BoolProperty.xaml,双击 BoolProperty.xaml.cs 文件,将原有的模板代码替换为如下代码。
cs
using I2oT.Views.Scenes;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xamarin.Forms;
using Xamarin.Forms.Xaml;
namespace I2oT.UserControls
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class BoolProperty : ContentView
{
private bool initLoad = false;
public InstantSceneSubsetPage HostPage { get; set; }
public int SIID { get; set; } = 0x0001;
public int CIID { get; set; } = 0x0001;
public string Property { get; set; } = "Switch";
public int Type { get => 0x0002; }
public int Len { get; set; } = 0x0001;
public int Min { get => 0x0000; }
public int Max { get => 0x0001; }
public int Step { get => 0x0001; }
public bool DefaultValue { get; set; } = false;
public bool Value
{
get => vControl.IsToggled;
set => vControl.IsToggled = value;
}
public string Caption
{
set => lvCaption.Text = value.Trim();
}
public BoolProperty()
{
InitializeComponent();
initLoad = true;
Value = DefaultValue;
lvComment.Text = Value ? "ON" : "OFF";
}
public BoolProperty(InstantSceneSubsetPage hostPage, bool onOff)
{
InitializeComponent();
initLoad = true;
HostPage = hostPage;
Value = onOff;
lvComment.Text = Value ? "ON" : "OFF";
initLoad = false;
}
public void TurnOn()
{
vControl.IsToggled = true;
}
public void TurnOff()
{
vControl.IsToggled = false;
}
private void OnSwitchToggled(object sender, ToggledEventArgs e)
{
if (initLoad) return;
Value = e.Value;
lvComment.Text = Value ? "ON" : "OFF";
HostPage.ContentChanged = true;
UpdateHostProperties();
}
private void UpdateHostProperties()
{
if (Property == null) return;
bool propertyMatched = false;
for (var i = 0; i < HostPage.Properties.Count; i++)
{
if (HostPage.Properties[i].Contains(Property))
{
HostPage.Properties[i] = "\"" + Property + "\":" + (Value ? "1" : "0");
propertyMatched = true;
break;
}
}
if (!propertyMatched)
{
HostPage.Properties.Add("\"" + Property + "\":" + (Value ? "1" : "0"));
}
}
}
}
2.4 构造数据接口
2.4.1 基础属性
根据我的项目中对 BoolProperty 的要求,BoolProperty 具有 SIID/ CIID/ Property/ Type/ Len/ Min/ Max/ Step/ DefaultValue 共9个基础属性,这些属性都和物模型定义的基础模板有关,和如何构建自定义控件没有直接关系。对物模型不感兴趣的童鞋可以忽略这些定义。
2.4.2 可视化属性的 get/set
注意对 Caption 属性和 Value 属性的定义,代码中是这样子的。
Caption 属性对于 Bool Property 而言相当于一个只写属性,对 Caption 赋值相当于对 lvCaption 这个控件的 Text 进行了更新;访问 Caption 属性的值是不允许的(因为没有 get 选项),转而使用 lvCaption.Text 替代。
Value 属性是可读写的,如果从一个类的属性的角度看,Value 属性的内部影子变量是 vControl.IsToggled。
public string Caption
{
set => lvCaption.Text = value.Trim();
}
public bool Value
{
get => vControl.IsToggled;
set => vControl.IsToggled = value;
}
2.4.3 构造函数
定义了两个构造函数。默认构造函数简单地初始化了 vControl 和 lvComment 的值,也就是说,如果没有其它值的定义时,呈现一个无名称、关闭状态、备注为"OFF"的控件。
带参数的构造函数将其宿主页面和开关状态作为参数带进来,这时控件呈现出一个带名称的、指定状态的,相应备注的控件。
2.5 定义开关动作对数据的更新
对数据的更新只会来源于用户对 vControl(Switch 控件)的点击,使用事件处理函数 OnSwitchToggled 更新界面和 HostPage 的相关属性。
状态变化时,对 HostPage.ContentChanged 的赋真值是常用的数据更新做法,当 BoolProperty 这个子控件的值被修改时,也意味着其调用者页面(Host Page)的数据也发生了变化,当 HostPage 退出时可以作为提示保存的依据,同时调用者页面无需管理自身的数据更新标志。
后来的实测证明 Value= e.Value 这条语句没有必要,这是因为 vControl.IsToggled 就是 Value 属性的影子变量。
private void OnSwitchToggled(object sender, ToggledEventArgs e)
{
if (initLoad) return;
Value = e.Value;
lvComment.Text = Value ? "ON" : "OFF";
HostPage.ContentChanged = true;
UpdateHostProperties();
}
2.6 initLoad 变量的使用
initLoad 是一个局部变量,当控件在生成阶段为 True,生成结束为 False。用 initLoad 控制在生成期间不发生数据更新回调是非常重要的。这是因为在控件生成期间------也就是带参数的构造函数被执行时,要根据 HostPage 传来的 onOff 参数对 vControl 进行赋值,这个赋值会出发 OnSwitchToggled 函数,OnSwitchToggled 反而又去更新 HostPage 的值。这不是我们希望的动作。使用 initLaod 就可以解决这个问题,当处于生成阶段时, initLoad 为 True,事件处理函数中的
if (initLoad) return;
这句话就阻挡了在生成期间对 HostPage 的参数更新。
不加控制有可能会导致事实上的死循环的。
2.7 实现截图
这是双路可调 LED 智能灯具的实现,它具有开关(Switch)、亮度(Brightness)和色温(CCT)三个属性,其中"开关"属性采用了上述的 BoolProperty 控件,只要整体色调平稳,看起来也是蛮好看的(献丑了哦 ;)。
3. 小结
使用 Xamarin.Forms 实现自定义控件,远非像 N 多码神所说那么复杂。只要考虑到以下三个环节就可以轻松实现自定义控件。
- 一个完整的布局,布局中包含了必要的控件及其呈现方式
- 定义好暴露(public)的属性和内部控件的对应关系
- 修改内部控件状态/属性值时,更新所绑定的实体数据
- 使用 initLoad 避免控件和宿主页面之间的循环更新
把握好这4条,用原生的简单控件也能制作出优雅简洁的自定义控件,同时还不必定义一大堆 BindableProperty 及其回调函数。
欢迎评论指正,一同提升。