如何使用 Xamarin Forms 创建具有垂直粘性 header 和水平粘性第一列的 table?

How to create a table with vertically sticky header and horizontally sticky first column using Xamarin Forms?

显示表格数据时,我认为在某些情况下始终可见 header 行和始终可见的第一列确实可以提高 table 的可读性和整体可用性,尤其是如果 table 中有很多数据。当 table 必须同时支持水平和垂直滚动时会出现问题。在查看过去比赛的得分时,可以从 NBA 应用程序中找到这种 table 的一个很好的例子。这是来自 NBA Android 应用程序的示例图片: Example table from NBA mobile application

从图像中可以清楚地看到,header 行与实际 table 数据水平对齐,第一列与 table 数据垂直对齐。我不知道防止使用相同的触摸动作水平和垂直滚动是非自愿还是自愿决定,但这是一个我不关心的小细节。

我不知道如何使用 Xamarin Forms 实现它。我对闭源/付费解决方案不感兴趣,因为我想实际学习如何自己完成。我确实意识到我很可能必须为 Android 和 IOS 使用自定义渲染器。我目前的想法是我有一个绝对布局,其中包含以下元素:

使用此设置,我将捕获触摸事件并将其发送到另一个 listviews/scrollviews,从而同步滚动。事实上,通过将 table 数据设置在与第一列相同的垂直滚动视图中,我可以轻松实现第一列和实际 table 数据的同步滚动。但我不知道如何将水平滚动同步到 header 行,我相信这不能通过巧妙的组件结构来实现。到目前为止,我只在 Android 上进行了测试,我可以在滚动视图自定义渲染器的 OnTouchEvent 方法中捕获触摸事件,但我不知道如何将其发送到自定义的 header 行滚动视图渲染器。

这是一个草稿 XAML 说明我的方法。

<AbsoluteLayout xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             HorizontalOptions="FillAndExpand">
    <ScrollView
        Orientation="Horizontal"
        x:Name="HeaderScrollView"
        AbsoluteLayout.LayoutBounds="0,0,1,1"
        AbsoluteLayout.LayoutFlags="All">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="200" />
                <ColumnDefinition Width="100" />
                <ColumnDefinition Width="100" />
                <ColumnDefinition Width="100" />
                <ColumnDefinition Width="100" />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="50" />
            </Grid.RowDefinitions>
            <!-- Skip first column, leave it empty for stationary cell -->
            <Label Text="Column 1" Grid.Row="0" Grid.Column="1" />
            <Label Text="Column 2" Grid.Row="0" Grid.Column="2" />
            <Label Text="Column 3" Grid.Row="0" Grid.Column="3" />
            <Label Text="Column 4" Grid.Row="0" Grid.Column="4" />
        </Grid>
    </ScrollView>
    <ScrollView
        x:Name="FirstColumnScrollView"
        Orientation="Vertical"
        AbsoluteLayout.LayoutBounds="0,50,1,1"
        AbsoluteLayout.LayoutFlags="SizeProportional"
        BackgroundColor="Aqua">
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="200" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>
            <StackLayout
                Grid.Column="0"
                Grid.Row="0"
                BindableLayout.ItemsSource="{Binding DataSource}">
                <BindableLayout.ItemTemplate>
                    <DataTemplate>
                        <Grid>
                            <Grid.RowDefinitions>
                                <RowDefinition Height="50" />
                            </Grid.RowDefinitions>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="150" />
                            </Grid.ColumnDefinitions>
                            <Label Text="{Binding Column1}" Grid.Row="0" Grid.Column="0" />
                        </Grid>
                    </DataTemplate>
                </BindableLayout.ItemTemplate>
            </StackLayout>
            <ScrollView
                x:Name="TableDataScrollView"
                Grid.Column="1"
                Grid.Row="0"
                Orientation="Horizontal">
                <StackLayout
                    BindableLayout.ItemsSource="{Binding DataSource}">
                    <BindableLayout.ItemTemplate>
                        <DataTemplate>
                            <Grid>
                                <Grid.ColumnDefinitions>
                                    <ColumnDefinition Width="100" />
                                    <ColumnDefinition Width="100" />
                                    <ColumnDefinition Width="100" />
                                    <ColumnDefinition Width="100" />
                                </Grid.ColumnDefinitions>
                                <Grid.RowDefinitions>
                                    <RowDefinition Height="50" />
                                </Grid.RowDefinitions>
                                <Label Text="{Binding Column2}" Grid.Row="0" Grid.Column="0" />
                                <Label Text="{Binding Column3}" Grid.Row="0" Grid.Column="1" />
                                <Label Text="{Binding Column4}" Grid.Row="0" Grid.Column="2" />
                                <Label Text="{Binding Column5}" Grid.Row="0" Grid.Column="3" />
                            </Grid>
                        </DataTemplate>
                    </BindableLayout.ItemTemplate>
                </StackLayout>
            </ScrollView>
        </Grid>
    </ScrollView>
    <Label Text="First Column" BackgroundColor="White" AbsoluteLayout.LayoutBounds="0,0,200,50" />
</AbsoluteLayout>

如您所见,问题是 HeaderScrollView 和 TableDataScrollView 之间的水平滚动事件未共享,我不知道如何以可能的最佳方式或根本不知道如何完成此操作。

非常感谢所有对此的帮助和反馈!

您正在寻找的是具有冻结行和冻结列功能的 DataGrid 组件。有一些第三方组件可以满足您的要求。

Syncfusion、Telerik 和 Infragistics DataGrids 具有您正在寻找的功能。请参考以下链接。

也很少有开源 DataGrid 可用。但不确定它们是否具有行和列固定功能。检查以下链接。

对于开源,您可以将 Zumero DataGrid 用于 Xamarin.Forms。它支持水平和垂直滚动,可选顶部冻结 header 行,可选左侧冻结列等。您可以从下面的 link 下载示例代码。

Xamarin.Forms 的 Zumero DataGrid:https://github.com/zumero/DataGrid/tree/8caf4895e2cc4362da3dbdd4735b5c6eb1d2dec4

对于示例代码,如果您遇到以下错误,运行 作为管理员就可以了。

Build action 'EmbeddedResource' is not supported by one or more of the project's targets

感谢@Harikrishnan 和@Wendy Zang - MSFT 的帮助! Zumero DataGrid 启发我以不同于通常的运动事件处理流程的方式处理运动事件。我基本上为 AbsoluteLayout

创建了以下自定义渲染器
using Android.Content;
using Android.Views;
using Test.Droid;
using Test.Views;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using View = Android.Views.View;

[assembly: ExportRenderer(typeof(StatisticsTable), typeof(StatisticsTableRenderer))]
namespace Test.Droid
{
    public class StatisticsTableRenderer : ViewRenderer
    {
        private View _headerScrollView;
        private View _tableScrollView;
        private float _startX;
        public StatisticsTableRenderer(Context context) : base(context)
        {
        }

        public override bool OnInterceptTouchEvent(MotionEvent ev)
        {
            if (_headerScrollView == null || _tableScrollView == null)
            {
                // Completely dependant on the structure of XAML
                _headerScrollView = GetChildAt(0);
                _tableScrollView = GetChildAt(1);
            }
            return true;
        }

        public override bool OnTouchEvent(MotionEvent ev)
        {
            if (ev.Action == MotionEventActions.Down)
            {
                _startX = ev.GetX();
            }

            var headerScroll = false;
            if (_startX > _headerScrollView.GetX())
            {
                headerScroll = _headerScrollView.DispatchTouchEvent(ev);
            }
            var tableScroll = _tableScrollView.DispatchTouchEvent(ev);


            return headerScroll || tableScroll;
        }
    }
}

如您所见,我总是拦截运动事件,然后手动将其分派给 children。然而,这还不够。当运动事件未在其中启动时,我必须阻止 HeaderScrollView 滚动,因为如果运动事件未在其中启动,TableDataScrollView 将不会滚动。我还必须为这个 table 中的所有滚动视图创建自定义渲染器。 TableDataScrollView 和 HeaderScrollView 使用相同的自定义渲染器。自定义渲染器唯一实现的是 OnInterceptTouchEvent,如下所示:

public override bool OnInterceptTouchEvent(MotionEvent ev)
{
    return false;
}

我不太清楚为什么这是必要的,但它似乎对我有用。我想有时 HeaderScrollView 会拦截运动事件,这会导致 header 滚动而不滚动 table 数据。

问题 XAML 中的垂直滚动视图又名 FirstColumnScrollView 必须以不同方式实现运动事件处理,因为它是 TableDataScrollView 的 parent,我们现在在 [=32= 中处理运动事件] 方式而不是 bottom-to-top 的默认 Android 方式。这导致了 FirstColumnScrollView 将简单地处理运动事件而不将其传递给 TableDataScrollView 的问题,这将导致 header 和实际 table 数据彼此不同步。这就是我为其添加以下自定义渲染器的原因

using Android.Content;
using Android.Views;
using Test.Droid;
using Test.Views;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;
using View = Android.Views.View;

[assembly: ExportRenderer(typeof(ChildFirstScrollView), typeof(ChildFirstScrollViewRenderer))]
namespace Test.Droid
{
    public class ChildFirstScrollViewRenderer : ScrollViewRenderer
    {
        private View _childView;
        public ChildFirstScrollViewRenderer(Context context) : base(context)
        {
        }

        public override bool DispatchTouchEvent(MotionEvent e)
        {
            if (_childView == null)
            {
                _childView = GetChildAt(0);
            }

            _childView.DispatchTouchEvent(e);
            return base.DispatchTouchEvent(e);
        }

        public override bool OnInterceptTouchEvent(MotionEvent ev)
        {
            return true;
        }
    }
}

在这个 ScrollView 中,我们总是 intercept/handle 运动事件,我们总是在处理运动事件之前先将它发送到 child ScrollView。

我还必须对问题中显示的 XAML 做一些小的调整。我将 HeaderScrollView 的起始 X 设置为第一列的宽度,因此它实际上不会位于第一列的静态 header 下方。然而,这导致了问题,因为我无法使用 AbsoluteLayout 的宽度(为什么在 XAML 中这么难?)来计算 HeaderScrollView 的正确宽度。现在宽度的设置方式是 HeaderScrollView 的一部分将始终位于视口之外,导致最后一个 header 永远不会显示。所以我在 header 网格中添加了一个 "PaddingColumn",其宽度等于第一列。出于同样的原因,我还必须向 FirstColumnScrollView 网格添加 "PaddingRow"。

我必须做的另一件事是将 FirstColumnScrollView 内的网格间距设置为 0。否则,您可以启动仅滚动 header 的运动事件的小间隙而不是 table 数据。

这只是目前的 Android 解决方案,但如果我能完成的话,我会带着 iOS 的解决方案回来。