ItemsControl控制子组件的加载

为了实现一个高性能的组件,我们就不能依赖Item的index索引,因为非虚拟化情况下,1000个元素就是1000个延迟加载。我们需要实现的是用户拖动到1000个元素的时候,10个元素使瞬间被渲染出来的。

UI虚拟化的核心思想就是只渲染可视范围内的控件,所以它通常会搭配ScrollViewer控件一起使用,通过ScrollViewer控件中的VerticalOffset、HorizontalOffset、ViewportWidth、ViewportHeight等参数可以计算出在可视范围内应该显示的控件,当控件不被显示时将它从Panel中移出,这样就可以保证同一时间只渲染了有限的控件,而不是渲染所有控件,从而达到性能提升的目的。

上面的话有一些问题,WPF其实已经提供了判断在视口的方法,也把结果直接返回了, 逻辑流:

1. 用户滚动。
    
2. `VirtualizingStackPanel` 内部计算(判断哪些 Index 进入了视口)。
    
3. `VirtualizingStackPanel` 发现 Index 50 进入了视口。
    
4. VSP 调用 `PrepareContainerForItemOverride` (传入 Index 50 的容器)。
    

所以: 你不需要去问“它在不在视口里?”,只要这个方法被调用了,它就一定刚被放进视口(或者刚被生成)。 这就是为什么我们在那里做动画是绝对安全的。

引用的那段话里有一句:“不被显示时将它从Panel中移出”。 这在 WPF/Avalonia 的默认虚拟化行为(VirtualizationMode.Recycling)中是不准确的,且效率很低,真正的应该是滚出视口的没有被销毁或者移除,应该是替换数据容器。

UI虚拟化

我们来通过scrollViewer验证视口中的元素的数量,最直接的方法机会直接利用WPF控件的生命周期事件Loaded和Unloaded来实现。

<ItemsControl Grid.Row="1" ItemsSource="{Binding MyItems}">  
    <ItemsControl.ItemsPanel>  
        <ItemsPanelTemplate>  
            <VirtualizingStackPanel IsVirtualizing="True"   
                                    VirtualizationMode="Recycling" />  
        </ItemsPanelTemplate>  
    </ItemsControl.ItemsPanel>  
  
    <ItemsControl.Template>  
        <ControlTemplate TargetType="ItemsControl">  
            <ScrollViewer CanContentScroll="True"   
                            PanningMode="Both">  
                <ItemsPresenter />  
            </ScrollViewer>  
        </ControlTemplate>  
    </ItemsControl.Template>  
  
    <ItemsControl.ItemTemplate>  
        <DataTemplate>  
            <local:TestItemControl />  
        </DataTemplate>  
    </ItemsControl.ItemTemplate>  
</ItemsControl>

我们用上面的方法来开启虚拟化,我们需要关注的是ScrollViewer(滚动条) 和 VirtualizingStackPanel(布局面板) 之间。

<ItemsPanelTemplate>
    <VirtualizingStackPanel IsVirtualizing="True" 
                            VirtualizationMode="Recycling" />
</ItemsPanelTemplate>

默认情况:ItemsControl 默认使用的是普通的 StackPanel。StackPanel 很“傻”,它的逻辑是:“你有多少数据,我就画多长”。如果你有 1000 个数据,它就计算出 50,000 像素的高度,然后一次性渲染出来。

这里我们启用了VSP,这个面板实现了IScrollInfo 接口,它拥有“数学能力”,它不会傻傻地渲染所有东西,而是会问:“现在视口有多高?那我只需要算出这部分显示哪几个 Item 就可以了。”

VirtualizationMode="Recycling":这是高级优化。它告诉面板:“当一个 Item 滚出屏幕时,别销毁它,把它扔进回收站。等下一个 Item 滚进来时,别 new 新的,去回收站拿一个旧的刷上新数据用。”(这就是观察到内存锯齿的原因)。

<ControlTemplate TargetType="ItemsControl">
    <ScrollViewer ...>
        <ItemsPresenter />
    </ScrollViewer>
</ControlTemplate>

虚拟化的前提是 “有限的空间”,如果没有 ScrollViewer,ItemsControl 就会试图根据内容撑大自己,占据无限的空间,一旦拥有无限空间,VirtualizingStackPanel 就会认为“视口无限大”,于是它还是会渲染所有 1000 个元素。

<ScrollViewer CanContentScroll="True" ...> 这个步骤也是十分关键的,如果不写这个地方,就会设置为False,为物理滚动,scrollViewer组件会把自己当做单纯的图片浏览器。

VirtualizingStackPanel:

  • 计算:第 500 个元素对应的高度是多少?
  • 回收:把刚才显示的第 0-20 个元素的容器(ListBoxItem)扔进回收站。
  • 复用:从回收站捞出 20 个容器。
  • 绑定:把这 20 个容器的数据上下文(DataContext)换成第 500-520 条数据。
  • 生成:调用 PrepareContainerForItemOverride(这时候我们的动画代码介入)。
  • 布局:只测量和排列这 20 个容器。

ItemsPresenter:把这 20 个容器显示在屏幕上。 这就是为什么只写了几行 XAML,性能却提升了百倍的原因。一切都在这一套精密的协商机制中完成了。

Template

它是整个控件的骨架和皮肤,在这里我们定义了模板组件。

<ItemsControl.Template>  
    <ControlTemplate TargetType="ItemsControl">  
        <ScrollViewer CanContentScroll="True"   
PanningMode="Both">  
            <ItemsPresenter />  
        </ScrollViewer>  
    </ControlTemplate>  
</ItemsControl.Template>

ItemsPanel

这里设置的是布局模式,我们的虚拟化是否开启也是在这里放置的。除此以外还可以设置垂直堆叠或者图标墙(WrapPanel),或者是九宫格(UniformGrid)。

继承ItemsControl来自定义组件

我们现在先注册两个依赖属性:

public static readonly DependencyProperty StaggerIntervalProperty =  
    DependencyProperty.Register(nameof(StaggerInterval), typeof(TimeSpan), typeof(StaggeredItemsControl),   
new PropertyMetadata(TimeSpan.FromMilliseconds(50)));  
  
public static readonly DependencyProperty AnimationDurationProperty =  
    DependencyProperty.Register(nameof(AnimationDuration), typeof(TimeSpan), typeof(StaggeredItemsControl),   
new PropertyMetadata(TimeSpan.FromMilliseconds(500)));

再写他们的CLR包装器。

紧接着去编写构造函数:

static StaggeredItemsControl()  
{  
    // 告诉 WPF 去 Themes/Generic.xaml 找默认样式  
    DefaultStyleKeyProperty.OverrideMetadata(typeof(StaggeredItemsControl),   
new FrameworkPropertyMetadata(typeof(StaggeredItemsControl)));  
}

他会覆盖ItemControl的默认样式,去Themes/Generic.xaml中寻找专用的样式,这里先不看xaml代码,我们继续向下看:

这里我们定义三个私有属性:

private long _lastPrepareTick = 0;  
private int _currentBatchIndex = 0;  
private const long BatchThresholdTicks = 100 * 10000; // 100ms 阈值

第一个是记录上一次生成容器的时间,第二个事记录当前加载到了第几个,第三个是整个算法的时间基准,因为1ms = 10000Ticks,这里是Ticks单位的阈值。

  • 在 UI 交互心理学中,0.1 秒 (100ms) 是用户感觉“瞬间发生”和“有延迟”的界限。
  • 如果两个动作发生的时间差小于 100ms,计算机会认为它们是**“连贯的”**(比如程序正在疯狂加载列表,或者用户正在高速滚动)。
  • 如果时间差大于 100ms,计算机会认为**“断片了”(比如用户滚动了一下,停下来看了一会儿,手指又拨了一下)。 这行代码定义了:“多长时间的停顿算作是新的一波操作?”** 答案是:100 毫秒(即 1,000,000 Ticks)。

入场动画

private void PlayEntranceAnimation(FrameworkElement container, TimeSpan beginTime)  
{  
    // 容器可能是从回收站捡回来的,它可能还保持着 Opacity=1 或 Transform=0    // 必须强制把它打回“未显示”的原型  
    container.Opacity = 0;   
// 确保有位移变换对象  
    if (!(container.RenderTransform is TranslateTransform))  
    {        container.RenderTransform = new TranslateTransform(0, 50);  
    }    var translate = (TranslateTransform)container.RenderTransform;  
    translate.Y = 50; // 初始位置:下方 50px    // 1. 透明度动画 (0 -> 1)    var opacityAnim = new DoubleAnimation  
    {  
        To = 1,  
        Duration = AnimationDuration,  
        BeginTime = beginTime, // 注入延迟  
        EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }  
    };  
    // 2. 位移动画 (50 -> 0)    var moveAnim = new DoubleAnimation  
    {  
        To = 0,  
        Duration = AnimationDuration,  
        BeginTime = beginTime, // 注入延迟  
        EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }  
    };    // 使用 BeginAnimation 可以自动覆盖之前的动画,比较安全  
    container.BeginAnimation(UIElement.OpacityProperty, opacityAnim);  
    translate.BeginAnimation(TranslateTransform.YProperty, moveAnim);  
}

这里我们将容器的透明度都设置为0,因为容器是从回收里取回的,可能它还保留着加载后的特性,比如透明度=1或者transform=0,因此我们需要去清晰一下组件。

列表控件基类虚方法重载

protected override void PrepareContainerForItemOverride(DependencyObject element, object item)  
{  
    base.PrepareContainerForItemOverride(element, item);  
    // 只有 FrameworkElement 才能做动画 (ContentPresenter 或 ListBoxItem)    if (element is FrameworkElement container)  
    {        // A. 批次检测算法  
        long now = DateTime.Now.Ticks;  
        // 如果这次生成距离上次生成 < 100ms,视为同一批  
        if (now - _lastPrepareTick < BatchThresholdTicks)  
        {            _currentBatchIndex++;  
        }        else  
        {  
            // 否则视为新的一波操作(例如用户直接拖动滚动条到了新位置)  
            // 重置计数器,让这批的第一个元素立即显示  
            _currentBatchIndex = 0;  
        }        _lastPrepareTick = now;  
        // B. 计算延迟  
        var delay = TimeSpan.FromTicks(StaggerInterval.Ticks * _currentBatchIndex);  
        // C. 播放入场动画  
        PlayEntranceAnimation(container, delay);  
    }}

它的作用是将数据变成界面,将数据放入组件中,我们调用了base.PrepareContainerForItemOverride(element, item) 时。WPF会执行类似于element.DataContext = item;这样的操作,如果删掉,ui就拿不到数据。

long now = DateTime.Now.Ticks;这里会记录当前的tick,如果当前的tick和上一次生成的tick小于了100ms,那么就可以当做是同一批加载的。否则就会重置记录器,让这批容器的第一个元素立即显示, _lastPrepareTick = now; 来更新当前的tick。

var delay = TimeSpan.FromTicks(StaggerInterval.Ticks * _currentBatchIndex);则是用于计算延迟的,前面的是一个const,表示的是每个组件加载的延迟,后面则是我们的计数器,用来计算后面组件的延迟,然后播放入场动画。

又有一点我们需要注意的地方:DependencyObject是WPF依赖属性的基矢,它的设计很简陋,没有Opacity等属性,没有RenderTransform或者什么什么其他,这些都存在于FrameworkElement中,因此我们必须进行转型,否则就无法引用动画属性。

当代码运行的时候,这个element的类型取决于使用的控件类型,对于ItemsContrl,默认的容器是ContentPresenter,继承自FrameworkElement。 对于ListBox,默认的是ListBoxItme,继承自ContentContrl -> Contrl -> FrameworkElement,对于ComboBox,默认容器ComboBoxItem也是FrameworkElement,因此几乎所有情况下也是FrameworkElement。

cs的部分说完了,接下来我们来到style的xml部分。

Generic的样式

Themes/Generic.xaml 我们知道,这个是WPF框架底层硬编码的一个标准路径,如果我们想要自行实现一个自定义控件的样式,我们就必须放在这个位置的文件里。

WPF去绘制我们的自定义组件 StaggeredItemsControl 的时候,回去检查在自身有无显示设置Sytle属性(<local:StaggeredItemsControl Style="{...}" />),如果有的话,就会直接使用,没有的话就会去Application:App.xaml中检查有没有针对这个类型的隐式样式,如果还是没有,就回去检查默认样式,然后由于我们编写了 DefaultStyleKeyProperty.OverrideMetadata ,他就回去DLL内部寻找样式,这个时候WPF就会去程序集里寻找一个叫做themes的文件夹,寻找这个文件里的样式。

xaml样式,自定义组件样式

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"  
                    xmlns:local="clr-namespace:ItemControlsSolution"  
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">  
        <Style TargetType="{x:Type local:StaggeredItemsControl}">  
        <Setter Property="Background" Value="Transparent"/>  
        <Setter Property="BorderThickness" Value="0"/>  
        <Setter Property="ItemsPanel">  
            <Setter.Value>  
                <ItemsPanelTemplate>  
                    <VirtualizingStackPanel IsVirtualizing="True"   
VirtualizationMode="Recycling" />  
                </ItemsPanelTemplate>  
            </Setter.Value>  
        </Setter>  
        <Setter Property="Template">  
            <Setter.Value>  
                <ControlTemplate TargetType="{x:Type local:StaggeredItemsControl}">  
                    <Border Background="{TemplateBinding Background}"  
                            BorderBrush="{TemplateBinding BorderBrush}"  
                            BorderThickness="{TemplateBinding BorderThickness}">  
                        <ScrollViewer Focusable="False"  
                                      Padding="{TemplateBinding Padding}"  
                                      CanContentScroll="True"  
                                      PanningMode="Both">  
                            <ItemsPresenter />  
                        </ScrollViewer>  
                    </Border>  
                </ControlTemplate>  
            </Setter.Value>  
        </Setter>  
    </Style>  
    </ResourceDictionary>
<Style TargetType="{x:Type local:StaggeredItemsControl}">

这里引用了类型StaggerdItemsContrl,告诉了样式系统我们定义了一个组件样式,我们在cs代码中编写的DefaultStyleKeyProperty.OverrideMetadata正是通过这个TargetType匹配到了这里的xaml。

然后是基础的属性设置:

<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>

如果用户在使用时不写 <local:StaggeredItemsControl Background="Red" ...>,那么背景默认就是透明的,边框是 0,这里是默认的属性。

然后再往下就是布局策略:

<Setter Property="ItemsPanel">
    <Setter.Value>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel IsVirtualizing="True" 
                                    VirtualizationMode="Recycling" />
        </ItemsPanelTemplate>
    </Setter.Value>
</Setter>

我们抛弃了默认的StackPanel,并且采用了VirtualizingStackPanel,如果没有这段代码,10,000数据会一次性生成造成内存爆炸,ItemsPanel用于设置子元素的排列。

接下来是控件骨架部分,这里是控件的物理结构:

<Setter Property="Template">
    <Setter.Value>
        <ControlTemplate TargetType="{x:Type local:StaggeredItemsControl}">
            </ControlTemplate>
    </Setter.Value>
</Setter>

我们这里指定了一个控件模板,指定的类型也就是我们的自定义组件,我们进入控件模板看看里面定义的是什么:

<ControlTemplate TargetType="{x:Type local:StaggeredItemsControl}">  
    <Border Background="{TemplateBinding Background}"  
            BorderBrush="{TemplateBinding BorderBrush}"  
            BorderThickness="{TemplateBinding BorderThickness}">  
        <ScrollViewer Focusable="False"  
                      Padding="{TemplateBinding Padding}"  
                      CanContentScroll="True"  
                      PanningMode="Both">  
            <ItemsPresenter />  
        </ScrollViewer>  
    </Border>  
</ControlTemplate>

外壳Border部分绑定的是模板组件的背景色,如果用户传递了background的话,就会绑定到这个上面,下面的同理,有BorderBrush和BorderThickness。

然后就是我们的视口关键部分scrollViewer,我们开启了细节优化:Focusable="False"防止点击空白处时,焦点跑到滚动条上,导致原来的焦点丢失,PanningMode="Both":支持触摸屏的手指拖拽滚动。 CanContentScroll="True":虚拟化总开关。

  • 这就是之前讨论的“逻辑滚动”。它告诉 ScrollViewer:“不要按像素滚,要把滚动权交给里面的 Panel(也就是上面的 VirtualizingStackPanel)。”
<ItemsPresenter />

这个就是插槽。

平滑滚动

现在的滚动是比较僵硬的,因为这是ScrollViewer的默认行为,它是按照像素移动的,但是为了渲染所有子元素,导致虚拟化失败。而我们开启了CanContentScroll="True" 后,滚动机制变成了逻辑滚动,会变成一次滚一行,产生了僵硬感。

自从WPF4.5之后,微软引入了一个叫做像素级虚拟化的功能,我们需在Generic.xaml 的 ItemsPanelTemplate 中加一行代码: VirtualizingPanel.ScrollUnit="Pixel",这样就用了虚拟化的性能和像素级别的平滑滚动。

但是我们需要的是带有惯性的就像是滑动的那样,这个时候靠xaml是实现的不了的,最好的做法就是拦截鼠标滚动事件,取消默认行为来用动画改变VerticalOffset。

我们需要重载方法:OnApplyTemplate

public override void OnApplyTemplate()  
{  
    base.OnApplyTemplate();  
    _scrollViewer = FindVisualChild<ScrollViewer>(this);  
}

OnApplyTemplate 的目的是:在“图纸”变成“实体”的那一刻,把手伸进控件内部,抓住我们需要操作的零件(比如 ScrollViewer)。 代码不能写在构造函数里,因为执行构造函数的时候,此时,XAML 中的 ControlTemplate 还没开始工作,你的 ScrollViewer 此时还不存在,它只是 Generic.xaml 里的一行字。

必须在WPF找到Generic.xmal之后应用模板,OnApplyTemplate 被调用:<-- 就在这一刻! 所有的内部控件都生成完毕了,这个重载方法保证了ItemsControl自己的初始化逻辑被正常加载的同时还会寻找到一个类型是ScrollViewer的子控件。

然后我们看看寻找子控件的方法:

private static T FindVisualChild<T>(DependencyObject parent) where T : DependencyObject  
{  
    for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)  
    {        var child = VisualTreeHelper.GetChild(parent, i);  
        if (child is T t) return t;  
        var result = FindVisualChild<T>(child);  
        if (result != null) return result;  
    }    return null;  
}

这个方法就是深度优先搜索DFS的实现,他会沿着WPF的可视化树一层一层挖掘,直到找到这个子组件。

最后是滚轮事件拦截的方法重载实现

protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)  
{  
    if (_scrollViewer == null) return;  
    e.Handled = true;  
    double currentOffset = _scrollViewer.VerticalOffset;  
    double delta = e.Delta > 0 ? -48 : 48; // e.Delta > 0 是向上滚  
    double targetOffset = currentOffset + delta;  
    if (targetOffset < 0) targetOffset = 0;  
    if (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;  
    _scrollViewer.ScrollToVerticalOffset(targetOffset); }

WPF 的事件分两种,Preview 开头的是隧道事件(从外向内传),普通的(如 MouseWheel)是冒泡事件(从内向外传)。 为什么用 Preview? 因为 ScrollViewer 内部会捕获并处理冒泡的 MouseWheel 事件。如果你想在 ScrollViewer 动手之前就把事件截胡,必须用 OnPreviewMouseWheel。

if (_scrollViewer == null) return; e.Handled = true; // <--- 最关键的一行!

e.Handled = true:这行代码就是在这个事件的传播路径断开,表示事件已被处理。

手动计算 (Manual Calculation):

double currentOffset = _scrollViewer.VerticalOffset;
double delta = e.Delta > 0 ? -48 : 48;

鼠标滚轮每“咔哒”一下,通常 Delta 值是 120(正数向上,负数向下),- 48:这是一个魔法数字。

- 这里硬编码了每次滚动移动 48 像素。
    
- 这解决了之前“按 Item 滚动”太僵硬的问题(如果一个 Item 高 100px,之前滚一下跳 100px,现在只跳 48px)。
    
  • 方向:因为屏幕坐标系 Y 轴向下是正方向,所以向上滚(Delta > 0)要减小 Offset,向下滚要增加 Offset。

边界检查 (Clamping):

double targetOffset = currentOffset + delta;
if (targetOffset < 0) targetOffset = 0;
if (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;

这个是为了防止算出的位置超出范围(比如滚到了 -48px 或者滚过了最底部)。虽然 ScrollToVerticalOffset 内部通常有容错,但自己写一遍更安全。

_scrollViewer.ScrollToVerticalOffset(targetOffset);就是执行移动。

这就是全部实现了,但是XAML 的 ScrollUnit="Pixel" 是官方的原生实现,它支持惯性(Inertia)(手指拨动会滑行),支持平滑加速减速,我们这段只是生硬的线性移动。滚一下动 48px,立马停住,手感像是在操作机床,而不是现代 App。

WPF针对普通的鼠标滚轮并不支持鼠标滚轮惯性,因此想要实现像手机一样的丝滑惯性,就需要去用C#手写一个物理动画引擎来欺骗组件或者其他方案等等。。。 有使用Composition.Rendering实现也有通过二阶系统的实现。这个就是其他的笔记要讲的了,我个人是更喜欢二阶系统缓动实现的方式的,这个更贴近真实的手感。