PLC-IoT 网关开发札记(6): Xamarin.Forms 的 CollectionView 绑定了什么?

需求:使用 CollectionView 呈现数据列表和按钮动作

项目开发中不可避免地会遇到在一个页面中呈现列表的情况,使用 CollectionView 作为容器是很方便的。CollectionView 中显示的数据对应于后台的一个 IEnumerable 派生的列表,常用的是 List<T> 和 Vector<T>,我习惯于使用 List<T> 作为后台的数据表。

CollectionView 的每一项对应后台的 List<T> 的一条记录。在网关应用中,有一个页面要列出所有的场景,单击(不论是鼠标还是手指单点一下)执行这个场景,单击条目右侧的“配置...”按钮对这个场景进行配置。

CollectionView 的 SelectionMode=“Single”,SelectionChanged 事件响应对这个条目的单击。在这个页面中,CollectionView 的每一条用一个 Grid 包装,包括了一个引导图标,一个主条目 Label 显示这个场景的名称,一个付条目 Labe 显示这个场景的类型,右侧的装填了一个“配置”按钮。 两个 Label 的 Text 可以在 XAML 中用显示绑定的方式显示对应的属性,但问题来了,“配置”按钮应该绑定什么呢?也就是说,对这个条目中包含的无绑定控件,怎么判断是哪一个条目的“配置”按钮被点击了呢?

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="I2oT.Views.ScenesPage"
             Title="场景">

    <ContentPage.Resources>
        <Style TargetType="Button">
            <Setter Property="VisualStateManager.VisualStateGroups">
                <VisualStateGroupList>
                    <VisualStateGroup x:Name="CommonStates">
                        <VisualState x:Name="Normal" >
                            <VisualState.Setters>
                                <Setter Property="Scale" Value="1" />
                            </VisualState.Setters>
                        </VisualState>

                        <VisualState x:Name="Pressed">
                            <VisualState.Setters>
                                <Setter Property="Scale" Value="0.9" />
                            </VisualState.Setters>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateGroupList>
            </Setter>

            <Setter Property="TextColor" Value="{StaticResource AppForegroundColor}"/>
            <Setter Property="BackgroundColor" Value="{StaticResource AppBackgroundColor}"/>
            <Setter Property="FontSize" Value="Caption"/>
            <Setter Property="HeightRequest" Value="32"/>
            <Setter Property="MinimumHeightRequest" Value="10"/>
            <Setter Property="CornerRadius" Value="2"/>
            <Setter Property="Padding" Value="4"/>
            <Setter Property="HorizontalOptions" Value="Start"/>

        </Style>

        <Style x:Key="ItemButtonStyle" TargetType="Button">
            <Setter Property="VisualStateManager.VisualStateGroups">
                <VisualStateGroupList>
                    <VisualStateGroup x:Name="CommonStates">
                        <VisualState x:Name="Normal" >
                            <VisualState.Setters>
                                <Setter Property="Scale" Value="1" />
                            </VisualState.Setters>
                        </VisualState>

                        <VisualState x:Name="Pressed">
                            <VisualState.Setters>
                                <Setter Property="Scale" Value="0.8" />
                            </VisualState.Setters>
                        </VisualState>
                    </VisualStateGroup>
                </VisualStateGroupList>
            </Setter>

            <Setter Property="TextColor" Value="{StaticResource AppTextCommonColor}"/>
            <Setter Property="BackgroundColor" Value="Transparent"/>
            <Setter Property="FontSize" Value="Caption"/>
            <Setter Property="HeightRequest" Value="32"/>
            <Setter Property="MinimumHeightRequest" Value="10"/>
            <Setter Property="BorderColor" Value="{StaticResource AppTextCommonColor}"/>
            <Setter Property="BorderWidth" Value="0.5"/>
            <Setter Property="CornerRadius" Value="2"/>
            <Setter Property="Padding" Value="4"/>
            <Setter Property="VerticalOptions" Value="Center"/>
            <Setter Property="HorizontalOptions" Value="Start"/>
            <Setter Property="Margin" Value="4,0"/>
            <Setter Property="CharacterSpacing" Value="1"/>
        </Style>
    </ContentPage.Resources>

    <ContentPage.ToolbarItems>
        <ToolbarItem Text="刷新" Clicked="RefreshSubsetList"/>
        <ToolbarItem Text="添加" Clicked="OnAddSceneClicked"/>
    </ContentPage.ToolbarItems>

    <CollectionView x:Name="collectionView"
                Margin="{StaticResource PageMargin}"
                SelectionMode="Single"
                SelectionChanged="OnSelectionChanged">

        <CollectionView.Header>
            <ScrollView Orientation="Horizontal">
                <StackLayout Orientation="Horizontal" >
                    <Button x:Name="btnInstantScene" Text="即时场景" Clicked="DisplayInstantScenes"/>
                    <Button x:Name="btnTimingScene" Text="定时场景" Clicked="DisplayTimingScenes"/>
                    <Button x:Name="btnSensorScene" Text="自动化场景" Clicked="DisplaySensorScenes"/>
                </StackLayout>
            </ScrollView>
        </CollectionView.Header>

        <CollectionView.ItemsLayout>
            <LinearItemsLayout Orientation="Vertical" ItemSpacing="8" />
        </CollectionView.ItemsLayout>

        <CollectionView.ItemTemplate>
            <DataTemplate>
                <StackLayout>
                    <Grid ColumnDefinitions="0.15*,*,0.4*">
                        <Image Grid.RowSpan="2"
                               Source="scene.png"
                               Aspect="AspectFit"
                               VerticalOptions="Start"
                               HeightRequest="20"
                               BackgroundColor="Transparent"/>
                        
                        <Label Grid.Row ="0"
                               Grid.Column="1"
                               Text="{Binding Name}"
                               FontSize="Small"
                               TextColor="{Binding ViewColor}"
                               BackgroundColor="Transparent"/>

                        <Label Grid.Row ="1" 
                               Grid.Column="1"
                               Text="{Binding Descriptive}"
                               TextColor="{StaticResource DescriptiveTextColor}"
                               FontSize="Caption" 
                               BackgroundColor="Transparent"/>

                        <Button Grid.RowSpan="2" Grid.Row="0" Grid.Column="2"
                                Text="配置..." Style="{StaticResource ItemButtonStyle}"
                                Clicked="OnDefineScene"/>
                    </Grid>
                </StackLayout>
            </DataTemplate>
        </CollectionView.ItemTemplate>

        <CollectionView.Footer>
            <Label x:Name="lbMessage" 
                   Text="Status"
                   FontSize="Caption" 
                   TextColor="{StaticResource AppTipIconColor}"
                   VerticalOptions="EndAndExpand"
                   HorizontalOptions="FillAndExpand"
                   HorizontalTextAlignment="Center"/>
        </CollectionView.Footer>
    </CollectionView>
</ContentPage>

Xamarin.Forms 的 CollectionView 中的子控件的 BindingContext

一开始我也对这个“绑定”感到手足无措,后来突然想到了一个办法:使用 Debug 模式,断点运行到 OnDefineScene 函数中,用 Shift+F9 查看一下是否有可用的线索。果然找到了!原来,在 CollectionView 条目中定义的子控件,不论是否显示地使用 {Binding xxxProperty} 进行绑定,这些子控件的 BindingContext 竟然就是被绑定列表的对应记录!

cs 代码

using I2oT.Data;
using I2oT.Models;
using I2oT.Views.Scenes;
using I2oT.Views.Subsets;
using I2oT.Views.SystemSettings;
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.Views
{
    [XamlCompilation(XamlCompilationOptions.Compile)]
    public partial class ScenesPage : ContentPage
    {
        private List<SceneModel> sceneList = null;
        private List<SceneModel> instantSceneList = null;
        private List<SceneModel> timingSceneList = null;
        private List<SceneModel> sensorSceneList = null;

        public ScenesPage()
        {
            InitializeComponent();
        }

        protected override void OnAppearing()
        {
            base.OnAppearing();

            RefreshSceneList(this, new EventArgs());
            lbMessage.Text = "";
        }

        private void RefreshSceneList(object sender, EventArgs e)
        {
            collectionView.ItemsSource = null;
            sceneList = (new SceneModel()).GetAll();
            collectionView.ItemsSource = sceneList;

            instantSceneList = new List<SceneModel>();
            timingSceneList = new List<SceneModel>();
            sensorSceneList = new List<SceneModel>();

            foreach (var sx in sceneList)
            {
                if (sx.Type == 1)
                {
                    instantSceneList.Add(sx);
                }
                else if (sx.Type == 2)
                {
                    timingSceneList.Add(sx);
                }
                else if (sx.Type == 3)
                {
                    sensorSceneList.Add(sx);
                }
            }

            btnInstantScene.Text = "即时场景 " + instantSceneList.Count().ToString();
            btnTimingScene.Text = "定时场景 " + timingSceneList.Count().ToString();
            btnSensorScene.Text = "自动化场景 " + sensorSceneList.Count().ToString();
        }

        private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            if (sender == null || e == null) return;

            SceneModel scene = (SceneModel)e.CurrentSelection.FirstOrDefault();
            if (scene == null) return;

            if (scene.Type != 1) return;

            // Only instant scene can be performed directly.
            if (scene.Type == 1)
            {
                App.Gateway.PerformScene(scene.ID);
                RefreshSubsetList(null, new EventArgs());
            }
        }

        private void OnDefineScene(object sender, EventArgs e)
        {
            var sx = (SceneModel)(((Button)sender).BindingContext);

            switch (sx.Type)
            {
                case 1:
                case 3:
                    Shell.Current.GoToAsync($"{nameof(InstantSceneDefinePage)}?{nameof(InstantSceneDefinePage.SceneID)}={sx.ID}");
                    break;
                case 2:
                    string uri = "";
                    uri += $"{nameof(TimingSceneDefinePage)}?";
                    uri += $"{nameof(TimingSceneDefinePage.SceneID)}={sx.ID}&";
                    uri += $"{nameof(TimingSceneDefinePage.SceneName)}={sx.Name}";

                    Shell.Current.GoToAsync(uri);
                    break;

                default:
                    break;
            }
        }

        private void OnAddSceneClicked(object sender, EventArgs e)
        {
            Shell.Current.GoToAsync($"{nameof(AddNewScenePage)}");
        }

        private void DisplayInstantScenes(object sender, EventArgs e)
        {
            collectionView.ItemsSource = instantSceneList;
        }

        private void DisplayTimingScenes(object sender, EventArgs e)
        {
            collectionView.ItemsSource = timingSceneList;
        }

        private void DisplaySensorScenes(object sender, EventArgs e)
        {
            collectionView.ItemsSource = sensorSceneList;
        }
    }
}

上述代码中,在 OnAppearing 方法中调用 RefreshSceneList 方法获取已定义的场景列表,列表中的每一个元素是一个 SceneModel (场景的数据模型),默认将全部场景列出,通过 ItemsSource 属性将 sceneList 绑定到 CollectionView。

断点观察

在 OnDefineScene 事件的第一条语句上设置断点,运行到此处暂停,然后 Shift+F9 打开快速监视,输入sender,(Button)sender,再输入((Button)sender).BindingContext,得到的计算值如下图所示。也就是说,这个配置按钮的 BindingContext 是 CollectionView 绑定的列表的当前元素!

哦吼,这下好办啦!直接将这个 SceneModel 的 ID 传递给下级页面就可以啦~

总结

一旦 CollectionView 的 ItemsSource 被赋值为一个类的列表,那么这个 CollectionView 的每一个条目中的任何控件的默认 BindingContext 就是这个列表的当前元素。

Xamarin.Forms 的 CollectionView 真真良心。