[{"data":1,"prerenderedAt":1706},["ShallowReactive",2],{"page-\u002Fcsharp\u002Fwpf\u002Fitemscontrol控制子组件的加载":3},{"id":4,"title":5,"body":6,"description":1698,"extension":1699,"meta":1700,"navigation":1701,"path":1702,"seo":1703,"stem":1704,"__hash__":1705},"content\u002Fcsharp\u002FWPF\u002FItemsControl控制子组件的加载.md","ItemsControl控制子组件的加载",{"type":7,"value":8,"toc":1687},"minimark",[9,21,40,47,58,68,78,83,86,232,246,271,291,302,322,352,371,374,380,427,439,443,450,495,499,502,506,509,550,553,556,591,594,597,617,620,647,650,779,782,785,889,896,899,906,909,912,915,919,922,925,929,1098,1107,1110,1113,1128,1135,1138,1183,1186,1189,1221,1224,1286,1289,1312,1323,1332,1335,1338,1344,1361,1364,1367,1395,1412,1425,1428,1476,1479,1482,1536,1571,1580,1588,1591,1606,1618,1624,1632,1635,1655,1662,1665,1680,1683],[10,11,12,13,17,18],"p",{},"为了实现一个高性能的组件，",[14,15,16],"strong",{},"我们就不能依赖Item的index索引","，因为非虚拟化情况下，1000个元素就是1000个延迟加载。",[14,19,20],{},"我们需要实现的是用户拖动到1000个元素的时候，10个元素使瞬间被渲染出来的。",[10,22,23,24,27,28,31,32,35,36,39],{},"UI虚拟化的核心思想就是",[14,25,26],{},"只渲染可视范围内的控件","，所以它通常会",[14,29,30],{},"搭配ScrollViewer控件一起使用","，",[14,33,34],{},"通过ScrollViewer控件中的VerticalOffset、HorizontalOffset、ViewportWidth、ViewportHeight等参数可以计算出在可视范围内应该显示的控件","，当控件",[14,37,38],{},"不被显示时将它从Panel中移出","，这样就可以保证同一时间只渲染了有限的控件，而不是渲染所有控件，从而达到性能提升的目的。",[10,41,42,43,46],{},"上面的话有一些问题，WPF其实已经提供了判断在视口的方法，也把结果直接返回了， ",[14,44,45],{},"逻辑流","：",[48,49,54],"pre",{"className":50,"code":52,"language":53},[51],"language-text","1. 用户滚动。\n    \n2. `VirtualizingStackPanel` 内部计算（判断哪些 Index 进入了视口）。\n    \n3. `VirtualizingStackPanel` 发现 Index 50 进入了视口。\n    \n4. VSP 调用 `PrepareContainerForItemOverride` (传入 Index 50 的容器)。\n    \n","text",[55,56,52],"code",{"__ignoreMap":57},"",[10,59,60,63,64,67],{},[14,61,62],{},"所以："," 你不需要去问“它在不在视口里？”，",[14,65,66],{},"只要这个方法被调用了，它就一定刚被放进视口（或者刚被生成）。"," 这就是为什么我们在那里做动画是绝对安全的。",[10,69,70,71,73,74,77],{},"引用的那段话里有一句：“",[14,72,38],{},"”。 这在 WPF\u002FAvalonia 的默认虚拟化行为（",[55,75,76],{},"VirtualizationMode.Recycling","）中是不准确的，且效率很低，真正的应该是滚出视口的没有被销毁或者移除，应该是替换数据容器。",[79,80,82],"h3",{"id":81},"ui虚拟化","UI虚拟化",[10,84,85],{},"我们来通过scrollViewer验证视口中的元素的数量，最直接的方法机会直接利用WPF控件的生命周期事件Loaded和Unloaded来实现。",[48,87,91],{"className":88,"code":89,"language":90,"meta":57,"style":57},"language-xml shiki shiki-themes github-light github-dark","\u003CItemsControl Grid.Row=\"1\" ItemsSource=\"{Binding MyItems}\">  \n    \u003CItemsControl.ItemsPanel>  \n        \u003CItemsPanelTemplate>  \n            \u003CVirtualizingStackPanel IsVirtualizing=\"True\"   \n                                    VirtualizationMode=\"Recycling\" \u002F>  \n        \u003C\u002FItemsPanelTemplate>  \n    \u003C\u002FItemsControl.ItemsPanel>  \n  \n    \u003CItemsControl.Template>  \n        \u003CControlTemplate TargetType=\"ItemsControl\">  \n            \u003CScrollViewer CanContentScroll=\"True\"   \n                            PanningMode=\"Both\">  \n                \u003CItemsPresenter \u002F>  \n            \u003C\u002FScrollViewer>  \n        \u003C\u002FControlTemplate>  \n    \u003C\u002FItemsControl.Template>  \n  \n    \u003CItemsControl.ItemTemplate>  \n        \u003CDataTemplate>  \n            \u003Clocal:TestItemControl \u002F>  \n        \u003C\u002FDataTemplate>  \n    \u003C\u002FItemsControl.ItemTemplate>  \n\u003C\u002FItemsControl>\n","xml",[55,92,93,101,107,113,119,125,131,137,143,149,155,161,167,173,179,185,191,196,202,208,214,220,226],{"__ignoreMap":57},[94,95,98],"span",{"class":96,"line":97},"line",1,[94,99,100],{},"\u003CItemsControl Grid.Row=\"1\" ItemsSource=\"{Binding MyItems}\">  \n",[94,102,104],{"class":96,"line":103},2,[94,105,106],{},"    \u003CItemsControl.ItemsPanel>  \n",[94,108,110],{"class":96,"line":109},3,[94,111,112],{},"        \u003CItemsPanelTemplate>  \n",[94,114,116],{"class":96,"line":115},4,[94,117,118],{},"            \u003CVirtualizingStackPanel IsVirtualizing=\"True\"   \n",[94,120,122],{"class":96,"line":121},5,[94,123,124],{},"                                    VirtualizationMode=\"Recycling\" \u002F>  \n",[94,126,128],{"class":96,"line":127},6,[94,129,130],{},"        \u003C\u002FItemsPanelTemplate>  \n",[94,132,134],{"class":96,"line":133},7,[94,135,136],{},"    \u003C\u002FItemsControl.ItemsPanel>  \n",[94,138,140],{"class":96,"line":139},8,[94,141,142],{},"  \n",[94,144,146],{"class":96,"line":145},9,[94,147,148],{},"    \u003CItemsControl.Template>  \n",[94,150,152],{"class":96,"line":151},10,[94,153,154],{},"        \u003CControlTemplate TargetType=\"ItemsControl\">  \n",[94,156,158],{"class":96,"line":157},11,[94,159,160],{},"            \u003CScrollViewer CanContentScroll=\"True\"   \n",[94,162,164],{"class":96,"line":163},12,[94,165,166],{},"                            PanningMode=\"Both\">  \n",[94,168,170],{"class":96,"line":169},13,[94,171,172],{},"                \u003CItemsPresenter \u002F>  \n",[94,174,176],{"class":96,"line":175},14,[94,177,178],{},"            \u003C\u002FScrollViewer>  \n",[94,180,182],{"class":96,"line":181},15,[94,183,184],{},"        \u003C\u002FControlTemplate>  \n",[94,186,188],{"class":96,"line":187},16,[94,189,190],{},"    \u003C\u002FItemsControl.Template>  \n",[94,192,194],{"class":96,"line":193},17,[94,195,142],{},[94,197,199],{"class":96,"line":198},18,[94,200,201],{},"    \u003CItemsControl.ItemTemplate>  \n",[94,203,205],{"class":96,"line":204},19,[94,206,207],{},"        \u003CDataTemplate>  \n",[94,209,211],{"class":96,"line":210},20,[94,212,213],{},"            \u003Clocal:TestItemControl \u002F>  \n",[94,215,217],{"class":96,"line":216},21,[94,218,219],{},"        \u003C\u002FDataTemplate>  \n",[94,221,223],{"class":96,"line":222},22,[94,224,225],{},"    \u003C\u002FItemsControl.ItemTemplate>  \n",[94,227,229],{"class":96,"line":228},23,[94,230,231],{},"\u003C\u002FItemsControl>\n",[10,233,234,235,238,239,245],{},"我们用上面的方法来开启虚拟化，我们需要关注的是",[55,236,237],{},"ScrollViewer","（滚动条） 和 ",[14,240,241,244],{},[55,242,243],{},"VirtualizingStackPanel","（布局面板）"," 之间。",[48,247,249],{"className":88,"code":248,"language":90,"meta":57,"style":57},"\u003CItemsPanelTemplate>\n    \u003CVirtualizingStackPanel IsVirtualizing=\"True\" \n                            VirtualizationMode=\"Recycling\" \u002F>\n\u003C\u002FItemsPanelTemplate>\n",[55,250,251,256,261,266],{"__ignoreMap":57},[94,252,253],{"class":96,"line":97},[94,254,255],{},"\u003CItemsPanelTemplate>\n",[94,257,258],{"class":96,"line":103},[94,259,260],{},"    \u003CVirtualizingStackPanel IsVirtualizing=\"True\" \n",[94,262,263],{"class":96,"line":109},[94,264,265],{},"                            VirtualizationMode=\"Recycling\" \u002F>\n",[94,267,268],{"class":96,"line":115},[94,269,270],{},"\u003C\u002FItemsPanelTemplate>\n",[10,272,273,46,276,279,280,283,284,286,287,290],{},[14,274,275],{},"默认情况",[55,277,278],{},"ItemsControl"," 默认使用的是普通的 ",[55,281,282],{},"StackPanel","。",[55,285,282],{}," 很“傻”，它的逻辑是：“你有多少数据，我就画多长”。",[14,288,289],{},"如果你有 1000 个数据，它就计算出 50,000 像素的高度","，然后一次性渲染出来。",[10,292,293,294,297,298,301],{},"这里我们启用了VSP，这个面板实现了",[55,295,296],{},"IScrollInfo"," 接口，它拥有“数学能力”，它不会傻傻地渲染所有东西，而是会问：“现在视口有多高？",[14,299,300],{},"那我只需要算出这部分显示哪几个 Item 就可以了","。”",[10,303,304,309,310,313,314,317,318,321],{},[14,305,306],{},[55,307,308],{},"VirtualizationMode=\"Recycling\"","：这是高级优化。它告诉面板：“当一个 Item 滚出屏幕时，",[14,311,312],{},"别销毁它","，把它扔进回收站。等下一个 Item 滚进来时，",[14,315,316],{},"别 new 新的","，去回收站拿一个旧的刷上新数据用。”（这就是观察到",[14,319,320],{},"内存锯齿","的原因）。",[48,323,325],{"className":88,"code":324,"language":90,"meta":57,"style":57},"\u003CControlTemplate TargetType=\"ItemsControl\">\n    \u003CScrollViewer ...>\n        \u003CItemsPresenter \u002F>\n    \u003C\u002FScrollViewer>\n\u003C\u002FControlTemplate>\n",[55,326,327,332,337,342,347],{"__ignoreMap":57},[94,328,329],{"class":96,"line":97},[94,330,331],{},"\u003CControlTemplate TargetType=\"ItemsControl\">\n",[94,333,334],{"class":96,"line":103},[94,335,336],{},"    \u003CScrollViewer ...>\n",[94,338,339],{"class":96,"line":109},[94,340,341],{},"        \u003CItemsPresenter \u002F>\n",[94,343,344],{"class":96,"line":115},[94,345,346],{},"    \u003C\u002FScrollViewer>\n",[94,348,349],{"class":96,"line":121},[94,350,351],{},"\u003C\u002FControlTemplate>\n",[10,353,354,355,358,359,31,361,363,364,367,368,370],{},"虚拟化的前提是 ",[14,356,357],{},"“有限的空间”","，如果没有 ",[55,360,237],{},[55,362,278],{}," 就会试图",[14,365,366],{},"根据内容撑大自己，占据无限的空间","，一旦拥有无限空间，",[55,369,243],{}," 就会认为“视口无限大”，于是它还是会渲染所有 1000 个元素。",[10,372,373],{},"\u003CScrollViewer CanContentScroll=\"True\" ...> 这个步骤也是十分关键的，如果不写这个地方，就会设置为False，为物理滚动，scrollViewer组件会把自己当做单纯的图片浏览器。",[10,375,376,46],{},[14,377,378],{},[55,379,243],{},[381,382,383,387,396,405,411,421],"ul",{},[384,385,386],"li",{},"计算：第 500 个元素对应的高度是多少？",[384,388,389,392,393,283],{},[14,390,391],{},"回收","：把",[14,394,395],{},"刚才显示的第 0-20 个元素的容器（ListBoxItem）扔进回收站",[384,397,398,401,402,283],{},[14,399,400],{},"复用","：从回收站",[14,403,404],{},"捞出 20 个容器",[384,406,407,410],{},[14,408,409],{},"绑定","：把这 20 个容器的数据上下文（DataContext）换成第 500-520 条数据。",[384,412,413,416,417,420],{},[14,414,415],{},"生成","：调用 ",[55,418,419],{},"PrepareContainerForItemOverride","（这时候我们的动画代码介入）。",[384,422,423,426],{},[14,424,425],{},"布局","：只测量和排列这 20 个容器。",[10,428,429,434,435,438],{},[14,430,431],{},[55,432,433],{},"ItemsPresenter","：把这 20 个容器显示在屏幕上。\n这就是为什么只写了几行 XAML，性能却提升了百倍的原因。一切都在这一套精密的",[14,436,437],{},"协商机制","中完成了。",[79,440,442],{"id":441},"template","Template",[10,444,445,446,449],{},"它是整个控件的",[14,447,448],{},"骨架和皮肤","，在这里我们定义了模板组件。",[48,451,453],{"className":88,"code":452,"language":90,"meta":57,"style":57},"\u003CItemsControl.Template>  \n    \u003CControlTemplate TargetType=\"ItemsControl\">  \n        \u003CScrollViewer CanContentScroll=\"True\"   \nPanningMode=\"Both\">  \n            \u003CItemsPresenter \u002F>  \n        \u003C\u002FScrollViewer>  \n    \u003C\u002FControlTemplate>  \n\u003C\u002FItemsControl.Template>\n",[55,454,455,460,465,470,475,480,485,490],{"__ignoreMap":57},[94,456,457],{"class":96,"line":97},[94,458,459],{},"\u003CItemsControl.Template>  \n",[94,461,462],{"class":96,"line":103},[94,463,464],{},"    \u003CControlTemplate TargetType=\"ItemsControl\">  \n",[94,466,467],{"class":96,"line":109},[94,468,469],{},"        \u003CScrollViewer CanContentScroll=\"True\"   \n",[94,471,472],{"class":96,"line":115},[94,473,474],{},"PanningMode=\"Both\">  \n",[94,476,477],{"class":96,"line":121},[94,478,479],{},"            \u003CItemsPresenter \u002F>  \n",[94,481,482],{"class":96,"line":127},[94,483,484],{},"        \u003C\u002FScrollViewer>  \n",[94,486,487],{"class":96,"line":133},[94,488,489],{},"    \u003C\u002FControlTemplate>  \n",[94,491,492],{"class":96,"line":139},[94,493,494],{},"\u003C\u002FItemsControl.Template>\n",[79,496,498],{"id":497},"itemspanel","ItemsPanel",[10,500,501],{},"这里设置的是布局模式，我们的虚拟化是否开启也是在这里放置的。除此以外还可以设置垂直堆叠或者图标墙（WrapPanel），或者是九宫格(UniformGrid)。",[79,503,505],{"id":504},"继承itemscontrol来自定义组件","继承ItemsControl来自定义组件",[10,507,508],{},"我们现在先注册两个依赖属性：",[48,510,514],{"className":511,"code":512,"language":513,"meta":57,"style":57},"language-cs shiki shiki-themes github-light github-dark","public static readonly DependencyProperty StaggerIntervalProperty =  \n    DependencyProperty.Register(nameof(StaggerInterval), typeof(TimeSpan), typeof(StaggeredItemsControl),   \nnew PropertyMetadata(TimeSpan.FromMilliseconds(50)));  \n  \npublic static readonly DependencyProperty AnimationDurationProperty =  \n    DependencyProperty.Register(nameof(AnimationDuration), typeof(TimeSpan), typeof(StaggeredItemsControl),   \nnew PropertyMetadata(TimeSpan.FromMilliseconds(500)));\n","cs",[55,515,516,521,526,531,535,540,545],{"__ignoreMap":57},[94,517,518],{"class":96,"line":97},[94,519,520],{},"public static readonly DependencyProperty StaggerIntervalProperty =  \n",[94,522,523],{"class":96,"line":103},[94,524,525],{},"    DependencyProperty.Register(nameof(StaggerInterval), typeof(TimeSpan), typeof(StaggeredItemsControl),   \n",[94,527,528],{"class":96,"line":109},[94,529,530],{},"new PropertyMetadata(TimeSpan.FromMilliseconds(50)));  \n",[94,532,533],{"class":96,"line":115},[94,534,142],{},[94,536,537],{"class":96,"line":121},[94,538,539],{},"public static readonly DependencyProperty AnimationDurationProperty =  \n",[94,541,542],{"class":96,"line":127},[94,543,544],{},"    DependencyProperty.Register(nameof(AnimationDuration), typeof(TimeSpan), typeof(StaggeredItemsControl),   \n",[94,546,547],{"class":96,"line":133},[94,548,549],{},"new PropertyMetadata(TimeSpan.FromMilliseconds(500)));\n",[10,551,552],{},"再写他们的CLR包装器。",[10,554,555],{},"紧接着去编写构造函数：",[48,557,559],{"className":511,"code":558,"language":513,"meta":57,"style":57},"static StaggeredItemsControl()  \n{  \n    \u002F\u002F 告诉 WPF 去 Themes\u002FGeneric.xaml 找默认样式  \n    DefaultStyleKeyProperty.OverrideMetadata(typeof(StaggeredItemsControl),   \nnew FrameworkPropertyMetadata(typeof(StaggeredItemsControl)));  \n}\n",[55,560,561,566,571,576,581,586],{"__ignoreMap":57},[94,562,563],{"class":96,"line":97},[94,564,565],{},"static StaggeredItemsControl()  \n",[94,567,568],{"class":96,"line":103},[94,569,570],{},"{  \n",[94,572,573],{"class":96,"line":109},[94,574,575],{},"    \u002F\u002F 告诉 WPF 去 Themes\u002FGeneric.xaml 找默认样式  \n",[94,577,578],{"class":96,"line":115},[94,579,580],{},"    DefaultStyleKeyProperty.OverrideMetadata(typeof(StaggeredItemsControl),   \n",[94,582,583],{"class":96,"line":121},[94,584,585],{},"new FrameworkPropertyMetadata(typeof(StaggeredItemsControl)));  \n",[94,587,588],{"class":96,"line":127},[94,589,590],{},"}\n",[10,592,593],{},"他会覆盖ItemControl的默认样式，去Themes\u002FGeneric.xaml中寻找专用的样式，这里先不看xaml代码，我们继续向下看：",[10,595,596],{},"这里我们定义三个私有属性：",[48,598,600],{"className":511,"code":599,"language":513,"meta":57,"style":57},"private long _lastPrepareTick = 0;  \nprivate int _currentBatchIndex = 0;  \nprivate const long BatchThresholdTicks = 100 * 10000; \u002F\u002F 100ms 阈值\n",[55,601,602,607,612],{"__ignoreMap":57},[94,603,604],{"class":96,"line":97},[94,605,606],{},"private long _lastPrepareTick = 0;  \n",[94,608,609],{"class":96,"line":103},[94,610,611],{},"private int _currentBatchIndex = 0;  \n",[94,613,614],{"class":96,"line":109},[94,615,616],{},"private const long BatchThresholdTicks = 100 * 10000; \u002F\u002F 100ms 阈值\n",[10,618,619],{},"第一个是记录上一次生成容器的时间，第二个事记录当前加载到了第几个，第三个是整个算法的时间基准，因为1ms = 10000Ticks，这里是Ticks单位的阈值。",[381,621,622,629,636],{},[384,623,624,625,628],{},"在 UI 交互心理学中，",[14,626,627],{},"0.1 秒 (100ms)"," 是用户感觉“瞬间发生”和“有延迟”的界限。",[384,630,631,632,635],{},"如果",[14,633,634],{},"两个动作发生的时间差小于 100ms","，计算机会认为它们是**“连贯的”**（比如程序正在疯狂加载列表，或者用户正在高速滚动）。",[384,637,638,639,642,643,646],{},"如果时间差大于 100ms，计算机会认为**“断片了”",[14,640,641],{},"（比如用户滚动了一下，停下来看了一会儿，手指又拨了一下）。\n这行代码定义了：","“多长时间的停顿算作是新的一波操作？”** 答案是：",[14,644,645],{},"100 毫秒","（即 1,000,000 Ticks）。",[79,648,649],{"id":649},"入场动画",[48,651,653],{"className":511,"code":652,"language":513,"meta":57,"style":57},"private void PlayEntranceAnimation(FrameworkElement container, TimeSpan beginTime)  \n{  \n    \u002F\u002F 容器可能是从回收站捡回来的，它可能还保持着 Opacity=1 或 Transform=0    \u002F\u002F 必须强制把它打回“未显示”的原型  \n    container.Opacity = 0;   \n\u002F\u002F 确保有位移变换对象  \n    if (!(container.RenderTransform is TranslateTransform))  \n    {        container.RenderTransform = new TranslateTransform(0, 50);  \n    }    var translate = (TranslateTransform)container.RenderTransform;  \n    translate.Y = 50; \u002F\u002F 初始位置：下方 50px    \u002F\u002F 1. 透明度动画 (0 -> 1)    var opacityAnim = new DoubleAnimation  \n    {  \n        To = 1,  \n        Duration = AnimationDuration,  \n        BeginTime = beginTime, \u002F\u002F 注入延迟  \n        EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }  \n    };  \n    \u002F\u002F 2. 位移动画 (50 -> 0)    var moveAnim = new DoubleAnimation  \n    {  \n        To = 0,  \n        Duration = AnimationDuration,  \n        BeginTime = beginTime, \u002F\u002F 注入延迟  \n        EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }  \n    };    \u002F\u002F 使用 BeginAnimation 可以自动覆盖之前的动画，比较安全  \n    container.BeginAnimation(UIElement.OpacityProperty, opacityAnim);  \n    translate.BeginAnimation(TranslateTransform.YProperty, moveAnim);  \n}\n",[55,654,655,660,664,672,677,682,687,692,697,702,707,712,717,722,727,732,737,741,746,750,754,758,763,768,774],{"__ignoreMap":57},[94,656,657],{"class":96,"line":97},[94,658,659],{},"private void PlayEntranceAnimation(FrameworkElement container, TimeSpan beginTime)  \n",[94,661,662],{"class":96,"line":103},[94,663,570],{},[94,665,666,669],{"class":96,"line":109},[94,667,668],{},"    \u002F\u002F 容器可能是从回收站捡回来的，它可能还保持着 Opacity=1 或 Transform=0",[94,670,671],{},"    \u002F\u002F 必须强制把它打回“未显示”的原型  \n",[94,673,674],{"class":96,"line":115},[94,675,676],{},"    container.Opacity = 0;   \n",[94,678,679],{"class":96,"line":121},[94,680,681],{},"\u002F\u002F 确保有位移变换对象  \n",[94,683,684],{"class":96,"line":127},[94,685,686],{},"    if (!(container.RenderTransform is TranslateTransform))  \n",[94,688,689],{"class":96,"line":133},[94,690,691],{},"    {        container.RenderTransform = new TranslateTransform(0, 50);  \n",[94,693,694],{"class":96,"line":139},[94,695,696],{},"    }    var translate = (TranslateTransform)container.RenderTransform;  \n",[94,698,699],{"class":96,"line":145},[94,700,701],{},"    translate.Y = 50; \u002F\u002F 初始位置：下方 50px    \u002F\u002F 1. 透明度动画 (0 -> 1)    var opacityAnim = new DoubleAnimation  \n",[94,703,704],{"class":96,"line":151},[94,705,706],{},"    {  \n",[94,708,709],{"class":96,"line":157},[94,710,711],{},"        To = 1,  \n",[94,713,714],{"class":96,"line":163},[94,715,716],{},"        Duration = AnimationDuration,  \n",[94,718,719],{"class":96,"line":169},[94,720,721],{},"        BeginTime = beginTime, \u002F\u002F 注入延迟  \n",[94,723,724],{"class":96,"line":175},[94,725,726],{},"        EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }  \n",[94,728,729],{"class":96,"line":181},[94,730,731],{},"    };  \n",[94,733,734],{"class":96,"line":187},[94,735,736],{},"    \u002F\u002F 2. 位移动画 (50 -> 0)    var moveAnim = new DoubleAnimation  \n",[94,738,739],{"class":96,"line":193},[94,740,706],{},[94,742,743],{"class":96,"line":198},[94,744,745],{},"        To = 0,  \n",[94,747,748],{"class":96,"line":204},[94,749,716],{},[94,751,752],{"class":96,"line":210},[94,753,721],{},[94,755,756],{"class":96,"line":216},[94,757,726],{},[94,759,760],{"class":96,"line":222},[94,761,762],{},"    };    \u002F\u002F 使用 BeginAnimation 可以自动覆盖之前的动画，比较安全  \n",[94,764,765],{"class":96,"line":228},[94,766,767],{},"    container.BeginAnimation(UIElement.OpacityProperty, opacityAnim);  \n",[94,769,771],{"class":96,"line":770},24,[94,772,773],{},"    translate.BeginAnimation(TranslateTransform.YProperty, moveAnim);  \n",[94,775,777],{"class":96,"line":776},25,[94,778,590],{},[10,780,781],{},"这里我们将容器的透明度都设置为0，因为容器是从回收里取回的，可能它还保留着加载后的特性，比如透明度=1或者transform=0，因此我们需要去清晰一下组件。",[79,783,784],{"id":784},"列表控件基类虚方法重载",[48,786,788],{"className":511,"code":787,"language":513,"meta":57,"style":57},"protected override void PrepareContainerForItemOverride(DependencyObject element, object item)  \n{  \n    base.PrepareContainerForItemOverride(element, item);  \n    \u002F\u002F 只有 FrameworkElement 才能做动画 (ContentPresenter 或 ListBoxItem)    if (element is FrameworkElement container)  \n    {        \u002F\u002F A. 批次检测算法  \n        long now = DateTime.Now.Ticks;  \n        \u002F\u002F 如果这次生成距离上次生成 \u003C 100ms，视为同一批  \n        if (now - _lastPrepareTick \u003C BatchThresholdTicks)  \n        {            _currentBatchIndex++;  \n        }        else  \n        {  \n            \u002F\u002F 否则视为新的一波操作（例如用户直接拖动滚动条到了新位置）  \n            \u002F\u002F 重置计数器，让这批的第一个元素立即显示  \n            _currentBatchIndex = 0;  \n        }        _lastPrepareTick = now;  \n        \u002F\u002F B. 计算延迟  \n        var delay = TimeSpan.FromTicks(StaggerInterval.Ticks * _currentBatchIndex);  \n        \u002F\u002F C. 播放入场动画  \n        PlayEntranceAnimation(container, delay);  \n    }}\n",[55,789,790,795,799,804,809,814,819,824,829,834,839,844,849,854,859,864,869,874,879,884],{"__ignoreMap":57},[94,791,792],{"class":96,"line":97},[94,793,794],{},"protected override void PrepareContainerForItemOverride(DependencyObject element, object item)  \n",[94,796,797],{"class":96,"line":103},[94,798,570],{},[94,800,801],{"class":96,"line":109},[94,802,803],{},"    base.PrepareContainerForItemOverride(element, item);  \n",[94,805,806],{"class":96,"line":115},[94,807,808],{},"    \u002F\u002F 只有 FrameworkElement 才能做动画 (ContentPresenter 或 ListBoxItem)    if (element is FrameworkElement container)  \n",[94,810,811],{"class":96,"line":121},[94,812,813],{},"    {        \u002F\u002F A. 批次检测算法  \n",[94,815,816],{"class":96,"line":127},[94,817,818],{},"        long now = DateTime.Now.Ticks;  \n",[94,820,821],{"class":96,"line":133},[94,822,823],{},"        \u002F\u002F 如果这次生成距离上次生成 \u003C 100ms，视为同一批  \n",[94,825,826],{"class":96,"line":139},[94,827,828],{},"        if (now - _lastPrepareTick \u003C BatchThresholdTicks)  \n",[94,830,831],{"class":96,"line":145},[94,832,833],{},"        {            _currentBatchIndex++;  \n",[94,835,836],{"class":96,"line":151},[94,837,838],{},"        }        else  \n",[94,840,841],{"class":96,"line":157},[94,842,843],{},"        {  \n",[94,845,846],{"class":96,"line":163},[94,847,848],{},"            \u002F\u002F 否则视为新的一波操作（例如用户直接拖动滚动条到了新位置）  \n",[94,850,851],{"class":96,"line":169},[94,852,853],{},"            \u002F\u002F 重置计数器，让这批的第一个元素立即显示  \n",[94,855,856],{"class":96,"line":175},[94,857,858],{},"            _currentBatchIndex = 0;  \n",[94,860,861],{"class":96,"line":181},[94,862,863],{},"        }        _lastPrepareTick = now;  \n",[94,865,866],{"class":96,"line":187},[94,867,868],{},"        \u002F\u002F B. 计算延迟  \n",[94,870,871],{"class":96,"line":193},[94,872,873],{},"        var delay = TimeSpan.FromTicks(StaggerInterval.Ticks * _currentBatchIndex);  \n",[94,875,876],{"class":96,"line":198},[94,877,878],{},"        \u002F\u002F C. 播放入场动画  \n",[94,880,881],{"class":96,"line":204},[94,882,883],{},"        PlayEntranceAnimation(container, delay);  \n",[94,885,886],{"class":96,"line":210},[94,887,888],{},"    }}\n",[10,890,891,892,895],{},"它的作用是将数据变成界面，将数据放入组件中，我们调用了",[55,893,894],{},"base.PrepareContainerForItemOverride(element, item)"," 时。WPF会执行类似于element.DataContext = item;这样的操作，如果删掉，ui就拿不到数据。",[10,897,898],{},"long now = DateTime.Now.Ticks;这里会记录当前的tick，如果当前的tick和上一次生成的tick小于了100ms，那么就可以当做是同一批加载的。否则就会重置记录器，让这批容器的第一个元素立即显示， _lastPrepareTick = now; 来更新当前的tick。",[10,900,901,902,905],{},"var delay = TimeSpan.FromTicks(StaggerInterval.Ticks * _currentBatchIndex);则是用于计算延迟的，前面的是一个const，表示的是每个组件加载的延迟，后面则是我们的",[14,903,904],{},"计数器","，用来计算后面组件的延迟，然后播放入场动画。",[10,907,908],{},"又有一点我们需要注意的地方：DependencyObject是WPF依赖属性的基矢，它的设计很简陋，没有Opacity等属性，没有RenderTransform或者什么什么其他，这些都存在于FrameworkElement中，因此我们必须进行转型，否则就无法引用动画属性。",[10,910,911],{},"当代码运行的时候，这个element的类型取决于使用的控件类型，对于ItemsContrl，默认的容器是ContentPresenter，继承自FrameworkElement。 对于ListBox，默认的是ListBoxItme，继承自ContentContrl -> Contrl -> FrameworkElement，对于ComboBox，默认容器ComboBoxItem也是FrameworkElement，因此几乎所有情况下也是FrameworkElement。",[10,913,914],{},"cs的部分说完了，接下来我们来到style的xml部分。",[79,916,918],{"id":917},"generic的样式","Generic的样式",[10,920,921],{},"Themes\u002FGeneric.xaml 我们知道，这个是WPF框架底层硬编码的一个标准路径，如果我们想要自行实现一个自定义控件的样式，我们就必须放在这个位置的文件里。",[10,923,924],{},"WPF去绘制我们的自定义组件 StaggeredItemsControl 的时候，回去检查在自身有无显示设置Sytle属性（\u003Clocal:StaggeredItemsControl Style=\"{...}\" \u002F>），如果有的话，就会直接使用，没有的话就会去Application:App.xaml中检查有没有针对这个类型的隐式样式，如果还是没有，就回去检查默认样式，然后由于我们编写了 DefaultStyleKeyProperty.OverrideMetadata ，他就回去DLL内部寻找样式，这个时候WPF就会去程序集里寻找一个叫做themes的文件夹，寻找这个文件里的样式。",[79,926,928],{"id":927},"xaml样式自定义组件样式","xaml样式，自定义组件样式",[48,930,932],{"className":88,"code":931,"language":90,"meta":57,"style":57},"\u003CResourceDictionary xmlns=\"http:\u002F\u002Fschemas.microsoft.com\u002Fwinfx\u002F2006\u002Fxaml\u002Fpresentation\"  \n                    xmlns:local=\"clr-namespace:ItemControlsSolution\"  \n                    xmlns:x=\"http:\u002F\u002Fschemas.microsoft.com\u002Fwinfx\u002F2006\u002Fxaml\">  \n        \u003CStyle TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n        \u003CSetter Property=\"Background\" Value=\"Transparent\"\u002F>  \n        \u003CSetter Property=\"BorderThickness\" Value=\"0\"\u002F>  \n        \u003CSetter Property=\"ItemsPanel\">  \n            \u003CSetter.Value>  \n                \u003CItemsPanelTemplate>  \n                    \u003CVirtualizingStackPanel IsVirtualizing=\"True\"   \nVirtualizationMode=\"Recycling\" \u002F>  \n                \u003C\u002FItemsPanelTemplate>  \n            \u003C\u002FSetter.Value>  \n        \u003C\u002FSetter>  \n        \u003CSetter Property=\"Template\">  \n            \u003CSetter.Value>  \n                \u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n                    \u003CBorder Background=\"{TemplateBinding Background}\"  \n                            BorderBrush=\"{TemplateBinding BorderBrush}\"  \n                            BorderThickness=\"{TemplateBinding BorderThickness}\">  \n                        \u003CScrollViewer Focusable=\"False\"  \n                                      Padding=\"{TemplateBinding Padding}\"  \n                                      CanContentScroll=\"True\"  \n                                      PanningMode=\"Both\">  \n                            \u003CItemsPresenter \u002F>  \n                        \u003C\u002FScrollViewer>  \n                    \u003C\u002FBorder>  \n                \u003C\u002FControlTemplate>  \n            \u003C\u002FSetter.Value>  \n        \u003C\u002FSetter>  \n    \u003C\u002FStyle>  \n    \u003C\u002FResourceDictionary>\n",[55,933,934,939,944,949,954,959,964,969,974,979,984,989,994,999,1004,1009,1013,1018,1023,1028,1033,1038,1043,1048,1053,1058,1064,1070,1076,1081,1086,1092],{"__ignoreMap":57},[94,935,936],{"class":96,"line":97},[94,937,938],{},"\u003CResourceDictionary xmlns=\"http:\u002F\u002Fschemas.microsoft.com\u002Fwinfx\u002F2006\u002Fxaml\u002Fpresentation\"  \n",[94,940,941],{"class":96,"line":103},[94,942,943],{},"                    xmlns:local=\"clr-namespace:ItemControlsSolution\"  \n",[94,945,946],{"class":96,"line":109},[94,947,948],{},"                    xmlns:x=\"http:\u002F\u002Fschemas.microsoft.com\u002Fwinfx\u002F2006\u002Fxaml\">  \n",[94,950,951],{"class":96,"line":115},[94,952,953],{},"        \u003CStyle TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n",[94,955,956],{"class":96,"line":121},[94,957,958],{},"        \u003CSetter Property=\"Background\" Value=\"Transparent\"\u002F>  \n",[94,960,961],{"class":96,"line":127},[94,962,963],{},"        \u003CSetter Property=\"BorderThickness\" Value=\"0\"\u002F>  \n",[94,965,966],{"class":96,"line":133},[94,967,968],{},"        \u003CSetter Property=\"ItemsPanel\">  \n",[94,970,971],{"class":96,"line":139},[94,972,973],{},"            \u003CSetter.Value>  \n",[94,975,976],{"class":96,"line":145},[94,977,978],{},"                \u003CItemsPanelTemplate>  \n",[94,980,981],{"class":96,"line":151},[94,982,983],{},"                    \u003CVirtualizingStackPanel IsVirtualizing=\"True\"   \n",[94,985,986],{"class":96,"line":157},[94,987,988],{},"VirtualizationMode=\"Recycling\" \u002F>  \n",[94,990,991],{"class":96,"line":163},[94,992,993],{},"                \u003C\u002FItemsPanelTemplate>  \n",[94,995,996],{"class":96,"line":169},[94,997,998],{},"            \u003C\u002FSetter.Value>  \n",[94,1000,1001],{"class":96,"line":175},[94,1002,1003],{},"        \u003C\u002FSetter>  \n",[94,1005,1006],{"class":96,"line":181},[94,1007,1008],{},"        \u003CSetter Property=\"Template\">  \n",[94,1010,1011],{"class":96,"line":187},[94,1012,973],{},[94,1014,1015],{"class":96,"line":193},[94,1016,1017],{},"                \u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n",[94,1019,1020],{"class":96,"line":198},[94,1021,1022],{},"                    \u003CBorder Background=\"{TemplateBinding Background}\"  \n",[94,1024,1025],{"class":96,"line":204},[94,1026,1027],{},"                            BorderBrush=\"{TemplateBinding BorderBrush}\"  \n",[94,1029,1030],{"class":96,"line":210},[94,1031,1032],{},"                            BorderThickness=\"{TemplateBinding BorderThickness}\">  \n",[94,1034,1035],{"class":96,"line":216},[94,1036,1037],{},"                        \u003CScrollViewer Focusable=\"False\"  \n",[94,1039,1040],{"class":96,"line":222},[94,1041,1042],{},"                                      Padding=\"{TemplateBinding Padding}\"  \n",[94,1044,1045],{"class":96,"line":228},[94,1046,1047],{},"                                      CanContentScroll=\"True\"  \n",[94,1049,1050],{"class":96,"line":770},[94,1051,1052],{},"                                      PanningMode=\"Both\">  \n",[94,1054,1055],{"class":96,"line":776},[94,1056,1057],{},"                            \u003CItemsPresenter \u002F>  \n",[94,1059,1061],{"class":96,"line":1060},26,[94,1062,1063],{},"                        \u003C\u002FScrollViewer>  \n",[94,1065,1067],{"class":96,"line":1066},27,[94,1068,1069],{},"                    \u003C\u002FBorder>  \n",[94,1071,1073],{"class":96,"line":1072},28,[94,1074,1075],{},"                \u003C\u002FControlTemplate>  \n",[94,1077,1079],{"class":96,"line":1078},29,[94,1080,998],{},[94,1082,1084],{"class":96,"line":1083},30,[94,1085,1003],{},[94,1087,1089],{"class":96,"line":1088},31,[94,1090,1091],{},"    \u003C\u002FStyle>  \n",[94,1093,1095],{"class":96,"line":1094},32,[94,1096,1097],{},"    \u003C\u002FResourceDictionary>\n",[48,1099,1101],{"className":88,"code":1100,"language":90,"meta":57,"style":57},"\u003CStyle TargetType=\"{x:Type local:StaggeredItemsControl}\">\n",[55,1102,1103],{"__ignoreMap":57},[94,1104,1105],{"class":96,"line":97},[94,1106,1100],{},[10,1108,1109],{},"这里引用了类型StaggerdItemsContrl，告诉了样式系统我们定义了一个组件样式，我们在cs代码中编写的DefaultStyleKeyProperty.OverrideMetadata正是通过这个TargetType匹配到了这里的xaml。",[10,1111,1112],{},"然后是基础的属性设置：",[48,1114,1116],{"className":88,"code":1115,"language":90,"meta":57,"style":57},"\u003CSetter Property=\"Background\" Value=\"Transparent\"\u002F>\n\u003CSetter Property=\"BorderThickness\" Value=\"0\"\u002F>\n",[55,1117,1118,1123],{"__ignoreMap":57},[94,1119,1120],{"class":96,"line":97},[94,1121,1122],{},"\u003CSetter Property=\"Background\" Value=\"Transparent\"\u002F>\n",[94,1124,1125],{"class":96,"line":103},[94,1126,1127],{},"\u003CSetter Property=\"BorderThickness\" Value=\"0\"\u002F>\n",[10,1129,1130,1131,1134],{},"如果用户在使用时不写 ",[55,1132,1133],{},"\u003Clocal:StaggeredItemsControl Background=\"Red\" ...>","，那么背景默认就是透明的，边框是 0，这里是默认的属性。",[10,1136,1137],{},"然后再往下就是布局策略：",[48,1139,1141],{"className":88,"code":1140,"language":90,"meta":57,"style":57},"\u003CSetter Property=\"ItemsPanel\">\n    \u003CSetter.Value>\n        \u003CItemsPanelTemplate>\n            \u003CVirtualizingStackPanel IsVirtualizing=\"True\" \n                                    VirtualizationMode=\"Recycling\" \u002F>\n        \u003C\u002FItemsPanelTemplate>\n    \u003C\u002FSetter.Value>\n\u003C\u002FSetter>\n",[55,1142,1143,1148,1153,1158,1163,1168,1173,1178],{"__ignoreMap":57},[94,1144,1145],{"class":96,"line":97},[94,1146,1147],{},"\u003CSetter Property=\"ItemsPanel\">\n",[94,1149,1150],{"class":96,"line":103},[94,1151,1152],{},"    \u003CSetter.Value>\n",[94,1154,1155],{"class":96,"line":109},[94,1156,1157],{},"        \u003CItemsPanelTemplate>\n",[94,1159,1160],{"class":96,"line":115},[94,1161,1162],{},"            \u003CVirtualizingStackPanel IsVirtualizing=\"True\" \n",[94,1164,1165],{"class":96,"line":121},[94,1166,1167],{},"                                    VirtualizationMode=\"Recycling\" \u002F>\n",[94,1169,1170],{"class":96,"line":127},[94,1171,1172],{},"        \u003C\u002FItemsPanelTemplate>\n",[94,1174,1175],{"class":96,"line":133},[94,1176,1177],{},"    \u003C\u002FSetter.Value>\n",[94,1179,1180],{"class":96,"line":139},[94,1181,1182],{},"\u003C\u002FSetter>\n",[10,1184,1185],{},"我们抛弃了默认的StackPanel，并且采用了VirtualizingStackPanel，如果没有这段代码，10,000数据会一次性生成造成内存爆炸，ItemsPanel用于设置子元素的排列。",[10,1187,1188],{},"接下来是控件骨架部分，这里是控件的物理结构：",[48,1190,1192],{"className":88,"code":1191,"language":90,"meta":57,"style":57},"\u003CSetter Property=\"Template\">\n    \u003CSetter.Value>\n        \u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">\n            \u003C\u002FControlTemplate>\n    \u003C\u002FSetter.Value>\n\u003C\u002FSetter>\n",[55,1193,1194,1199,1203,1208,1213,1217],{"__ignoreMap":57},[94,1195,1196],{"class":96,"line":97},[94,1197,1198],{},"\u003CSetter Property=\"Template\">\n",[94,1200,1201],{"class":96,"line":103},[94,1202,1152],{},[94,1204,1205],{"class":96,"line":109},[94,1206,1207],{},"        \u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">\n",[94,1209,1210],{"class":96,"line":115},[94,1211,1212],{},"            \u003C\u002FControlTemplate>\n",[94,1214,1215],{"class":96,"line":121},[94,1216,1177],{},[94,1218,1219],{"class":96,"line":127},[94,1220,1182],{},[10,1222,1223],{},"我们这里指定了一个控件模板，指定的类型也就是我们的自定义组件，我们进入控件模板看看里面定义的是什么：",[48,1225,1227],{"className":88,"code":1226,"language":90,"meta":57,"style":57},"\u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n    \u003CBorder Background=\"{TemplateBinding Background}\"  \n            BorderBrush=\"{TemplateBinding BorderBrush}\"  \n            BorderThickness=\"{TemplateBinding BorderThickness}\">  \n        \u003CScrollViewer Focusable=\"False\"  \n                      Padding=\"{TemplateBinding Padding}\"  \n                      CanContentScroll=\"True\"  \n                      PanningMode=\"Both\">  \n            \u003CItemsPresenter \u002F>  \n        \u003C\u002FScrollViewer>  \n    \u003C\u002FBorder>  \n\u003C\u002FControlTemplate>\n",[55,1228,1229,1234,1239,1244,1249,1254,1259,1264,1269,1273,1277,1282],{"__ignoreMap":57},[94,1230,1231],{"class":96,"line":97},[94,1232,1233],{},"\u003CControlTemplate TargetType=\"{x:Type local:StaggeredItemsControl}\">  \n",[94,1235,1236],{"class":96,"line":103},[94,1237,1238],{},"    \u003CBorder Background=\"{TemplateBinding Background}\"  \n",[94,1240,1241],{"class":96,"line":109},[94,1242,1243],{},"            BorderBrush=\"{TemplateBinding BorderBrush}\"  \n",[94,1245,1246],{"class":96,"line":115},[94,1247,1248],{},"            BorderThickness=\"{TemplateBinding BorderThickness}\">  \n",[94,1250,1251],{"class":96,"line":121},[94,1252,1253],{},"        \u003CScrollViewer Focusable=\"False\"  \n",[94,1255,1256],{"class":96,"line":127},[94,1257,1258],{},"                      Padding=\"{TemplateBinding Padding}\"  \n",[94,1260,1261],{"class":96,"line":133},[94,1262,1263],{},"                      CanContentScroll=\"True\"  \n",[94,1265,1266],{"class":96,"line":139},[94,1267,1268],{},"                      PanningMode=\"Both\">  \n",[94,1270,1271],{"class":96,"line":145},[94,1272,479],{},[94,1274,1275],{"class":96,"line":151},[94,1276,484],{},[94,1278,1279],{"class":96,"line":157},[94,1280,1281],{},"    \u003C\u002FBorder>  \n",[94,1283,1284],{"class":96,"line":163},[94,1285,351],{},[10,1287,1288],{},"外壳Border部分绑定的是模板组件的背景色，如果用户传递了background的话，就会绑定到这个上面，下面的同理，有BorderBrush和BorderThickness。",[10,1290,1291,1292,1303,1304,46,1309,283],{},"然后就是我们的视口关键部分scrollViewer，我们开启了细节优化：",[14,1293,1294,1297,1300],{},[55,1295,1296],{},"Focusable=\"False\"",[14,1298,1299],{},"防止点击空白处时，焦点跑到滚动条上，导致原来的焦点丢失，",[55,1301,1302],{},"PanningMode=\"Both\"","：支持触摸屏的手指拖拽滚动。\n",[14,1305,1306],{},[55,1307,1308],{},"CanContentScroll=\"True\"",[14,1310,1311],{},"虚拟化总开关",[381,1313,1314],{},[384,1315,1316,1317,1319,1320,1322],{},"这就是之前讨论的“逻辑滚动”。它告诉 ",[55,1318,237],{},"：“不要按像素滚，要把滚动权交给里面的 Panel（也就是上面的 ",[55,1321,243],{},"）。”",[48,1324,1326],{"className":88,"code":1325,"language":90,"meta":57,"style":57},"\u003CItemsPresenter \u002F>\n",[55,1327,1328],{"__ignoreMap":57},[94,1329,1330],{"class":96,"line":97},[94,1331,1325],{},[10,1333,1334],{},"这个就是插槽。",[79,1336,1337],{"id":1337},"平滑滚动",[10,1339,1340,1341,1343],{},"现在的滚动是比较僵硬的，因为这是ScrollViewer的默认行为，它是按照像素移动的，但是为了渲染所有子元素，导致虚拟化失败。而我们开启了",[55,1342,1308],{}," 后，滚动机制变成了逻辑滚动，会变成一次滚一行，产生了僵硬感。",[10,1345,1346,1347,1350,1351,1354,1355,1360],{},"自从WPF4.5之后，微软引入了一个叫做像素级虚拟化的功能，我们需在",[55,1348,1349],{},"Generic.xaml"," 的 ",[55,1352,1353],{},"ItemsPanelTemplate"," 中加一行代码： ",[14,1356,1357],{},[55,1358,1359],{},"VirtualizingPanel.ScrollUnit=\"Pixel\"","，这样就用了虚拟化的性能和像素级别的平滑滚动。",[10,1362,1363],{},"但是我们需要的是带有惯性的就像是滑动的那样，这个时候靠xaml是实现的不了的，最好的做法就是拦截鼠标滚动事件，取消默认行为来用动画改变VerticalOffset。",[10,1365,1366],{},"我们需要重载方法：OnApplyTemplate",[48,1368,1370],{"className":511,"code":1369,"language":513,"meta":57,"style":57},"public override void OnApplyTemplate()  \n{  \n    base.OnApplyTemplate();  \n    _scrollViewer = FindVisualChild\u003CScrollViewer>(this);  \n}\n",[55,1371,1372,1377,1381,1386,1391],{"__ignoreMap":57},[94,1373,1374],{"class":96,"line":97},[94,1375,1376],{},"public override void OnApplyTemplate()  \n",[94,1378,1379],{"class":96,"line":103},[94,1380,570],{},[94,1382,1383],{"class":96,"line":109},[94,1384,1385],{},"    base.OnApplyTemplate();  \n",[94,1387,1388],{"class":96,"line":115},[94,1389,1390],{},"    _scrollViewer = FindVisualChild\u003CScrollViewer>(this);  \n",[94,1392,1393],{"class":96,"line":121},[94,1394,590],{},[10,1396,1397,1400,1401,1404,1405,1408,1409,1411],{},[55,1398,1399],{},"OnApplyTemplate"," 的目的是：",[14,1402,1403],{},"在“图纸”变成“实体”的那一刻，把手伸进控件内部，抓住我们需要操作的零件（比如 ScrollViewer）。"," 代码不能写在构造函数里，因为执行构造函数的时候，此时，XAML 中的 ",[55,1406,1407],{},"ControlTemplate"," 还没开始工作，你的 ",[55,1410,237],{}," 此时还不存在，它只是 Generic.xaml 里的一行字。",[10,1413,1414,1415,1420,1421,1424],{},"必须在WPF找到Generic.xmal之后应用模板，",[14,1416,1417,1419],{},[55,1418,1399],{}," 被调用","：\u003C-- ",[14,1422,1423],{},"就在这一刻！"," 所有的内部控件都生成完毕了，这个重载方法保证了ItemsControl自己的初始化逻辑被正常加载的同时还会寻找到一个类型是ScrollViewer的子控件。",[10,1426,1427],{},"然后我们看看寻找子控件的方法：",[48,1429,1431],{"className":511,"code":1430,"language":513,"meta":57,"style":57},"private static T FindVisualChild\u003CT>(DependencyObject parent) where T : DependencyObject  \n{  \n    for (int i = 0; i \u003C VisualTreeHelper.GetChildrenCount(parent); i++)  \n    {        var child = VisualTreeHelper.GetChild(parent, i);  \n        if (child is T t) return t;  \n        var result = FindVisualChild\u003CT>(child);  \n        if (result != null) return result;  \n    }    return null;  \n}\n",[55,1432,1433,1438,1442,1447,1452,1457,1462,1467,1472],{"__ignoreMap":57},[94,1434,1435],{"class":96,"line":97},[94,1436,1437],{},"private static T FindVisualChild\u003CT>(DependencyObject parent) where T : DependencyObject  \n",[94,1439,1440],{"class":96,"line":103},[94,1441,570],{},[94,1443,1444],{"class":96,"line":109},[94,1445,1446],{},"    for (int i = 0; i \u003C VisualTreeHelper.GetChildrenCount(parent); i++)  \n",[94,1448,1449],{"class":96,"line":115},[94,1450,1451],{},"    {        var child = VisualTreeHelper.GetChild(parent, i);  \n",[94,1453,1454],{"class":96,"line":121},[94,1455,1456],{},"        if (child is T t) return t;  \n",[94,1458,1459],{"class":96,"line":127},[94,1460,1461],{},"        var result = FindVisualChild\u003CT>(child);  \n",[94,1463,1464],{"class":96,"line":133},[94,1465,1466],{},"        if (result != null) return result;  \n",[94,1468,1469],{"class":96,"line":139},[94,1470,1471],{},"    }    return null;  \n",[94,1473,1474],{"class":96,"line":145},[94,1475,590],{},[10,1477,1478],{},"这个方法就是深度优先搜索DFS的实现，他会沿着WPF的可视化树一层一层挖掘，直到找到这个子组件。",[10,1480,1481],{},"最后是滚轮事件拦截的方法重载实现",[48,1483,1485],{"className":511,"code":1484,"language":513,"meta":57,"style":57},"protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)  \n{  \n    if (_scrollViewer == null) return;  \n    e.Handled = true;  \n    double currentOffset = _scrollViewer.VerticalOffset;  \n    double delta = e.Delta > 0 ? -48 : 48; \u002F\u002F e.Delta > 0 是向上滚  \n    double targetOffset = currentOffset + delta;  \n    if (targetOffset \u003C 0) targetOffset = 0;  \n    if (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;  \n    _scrollViewer.ScrollToVerticalOffset(targetOffset); }\n",[55,1486,1487,1492,1496,1501,1506,1511,1516,1521,1526,1531],{"__ignoreMap":57},[94,1488,1489],{"class":96,"line":97},[94,1490,1491],{},"protected override void OnPreviewMouseWheel(MouseWheelEventArgs e)  \n",[94,1493,1494],{"class":96,"line":103},[94,1495,570],{},[94,1497,1498],{"class":96,"line":109},[94,1499,1500],{},"    if (_scrollViewer == null) return;  \n",[94,1502,1503],{"class":96,"line":115},[94,1504,1505],{},"    e.Handled = true;  \n",[94,1507,1508],{"class":96,"line":121},[94,1509,1510],{},"    double currentOffset = _scrollViewer.VerticalOffset;  \n",[94,1512,1513],{"class":96,"line":127},[94,1514,1515],{},"    double delta = e.Delta > 0 ? -48 : 48; \u002F\u002F e.Delta > 0 是向上滚  \n",[94,1517,1518],{"class":96,"line":133},[94,1519,1520],{},"    double targetOffset = currentOffset + delta;  \n",[94,1522,1523],{"class":96,"line":139},[94,1524,1525],{},"    if (targetOffset \u003C 0) targetOffset = 0;  \n",[94,1527,1528],{"class":96,"line":145},[94,1529,1530],{},"    if (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;  \n",[94,1532,1533],{"class":96,"line":151},[94,1534,1535],{},"    _scrollViewer.ScrollToVerticalOffset(targetOffset); }\n",[10,1537,1538,1539,1542,1543,1546,1547,1550,1551,1554,1555,1558,1559,1561,1562,1564,1565,1567,1568,283],{},"WPF 的事件分两种，",[55,1540,1541],{},"Preview"," 开头的是",[14,1544,1545],{},"隧道事件（从外向内传）","，普通的（如 ",[55,1548,1549],{},"MouseWheel","）是",[14,1552,1553],{},"冒泡事件（从内向外传）","。\n",[14,1556,1557],{},"为什么用 Preview？"," 因为 ",[55,1560,237],{}," 内部会捕获并处理冒泡的 ",[55,1563,1549],{}," 事件。如果你想在 ",[55,1566,237],{}," 动手之前就把事件截胡，必须用 ",[55,1569,1570],{},"OnPreviewMouseWheel",[48,1572,1574],{"className":511,"code":1573,"language":513,"meta":57,"style":57},"if (_scrollViewer == null) return; e.Handled = true; \u002F\u002F \u003C--- 最关键的一行！\n",[55,1575,1576],{"__ignoreMap":57},[94,1577,1578],{"class":96,"line":97},[94,1579,1573],{},[10,1581,1582,1587],{},[14,1583,1584],{},[55,1585,1586],{},"e.Handled = true","：这行代码就是在这个事件的传播路径断开，表示事件已被处理。",[10,1589,1590],{},"手动计算 (Manual Calculation)：",[48,1592,1594],{"className":511,"code":1593,"language":513,"meta":57,"style":57},"double currentOffset = _scrollViewer.VerticalOffset;\ndouble delta = e.Delta > 0 ? -48 : 48;\n",[55,1595,1596,1601],{"__ignoreMap":57},[94,1597,1598],{"class":96,"line":97},[94,1599,1600],{},"double currentOffset = _scrollViewer.VerticalOffset;\n",[94,1602,1603],{"class":96,"line":103},[94,1604,1605],{},"double delta = e.Delta > 0 ? -48 : 48;\n",[10,1607,1608,1609,1614,1615,283],{},"鼠标滚轮每“咔哒”一下，通常 Delta 值是 120（正数向上，负数向下），- ",[14,1610,1611],{},[55,1612,1613],{},"48","：这是一个",[14,1616,1617],{},"魔法数字",[48,1619,1622],{"className":1620,"code":1621,"language":53},[51],"- 这里硬编码了每次滚动移动 48 像素。\n    \n- 这解决了之前“按 Item 滚动”太僵硬的问题（如果一个 Item 高 100px，之前滚一下跳 100px，现在只跳 48px）。\n    \n",[55,1623,1621],{"__ignoreMap":57},[381,1625,1626],{},[384,1627,1628,1631],{},[14,1629,1630],{},"方向","：因为屏幕坐标系 Y 轴向下是正方向，所以向上滚（Delta > 0）要减小 Offset，向下滚要增加 Offset。",[10,1633,1634],{},"边界检查 (Clamping)：",[48,1636,1638],{"className":511,"code":1637,"language":513,"meta":57,"style":57},"double targetOffset = currentOffset + delta;\nif (targetOffset \u003C 0) targetOffset = 0;\nif (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;\n",[55,1639,1640,1645,1650],{"__ignoreMap":57},[94,1641,1642],{"class":96,"line":97},[94,1643,1644],{},"double targetOffset = currentOffset + delta;\n",[94,1646,1647],{"class":96,"line":103},[94,1648,1649],{},"if (targetOffset \u003C 0) targetOffset = 0;\n",[94,1651,1652],{"class":96,"line":109},[94,1653,1654],{},"if (targetOffset > _scrollViewer.ScrollableHeight) targetOffset = _scrollViewer.ScrollableHeight;\n",[10,1656,1657,1658,1661],{},"这个是为了防止算出的位置超出范围（比如滚到了 -48px 或者滚过了最底部）。虽然 ",[55,1659,1660],{},"ScrollToVerticalOffset"," 内部通常有容错，但自己写一遍更安全。",[10,1663,1664],{},"_scrollViewer.ScrollToVerticalOffset(targetOffset);就是执行移动。",[10,1666,1667,1668,1671,1672,1675,1676,1679],{},"这就是全部实现了，但是XAML 的 ",[55,1669,1670],{},"ScrollUnit=\"Pixel\""," 是官方的原生实现，它支持",[14,1673,1674],{},"惯性（Inertia）","（手指拨动会滑行），支持平滑加速减速，我们这段只是",[14,1677,1678],{},"生硬的线性移动","。滚一下动 48px，立马停住，手感像是在操作机床，而不是现代 App。",[10,1681,1682],{},"WPF针对普通的鼠标滚轮并不支持鼠标滚轮惯性，因此想要实现像手机一样的丝滑惯性，就需要去用C#手写一个物理动画引擎来欺骗组件或者其他方案等等。。。 有使用Composition.Rendering实现也有通过二阶系统的实现。这个就是其他的笔记要讲的了，我个人是更喜欢二阶系统缓动实现的方式的，这个更贴近真实的手感。",[1684,1685,1686],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":57,"searchDepth":103,"depth":103,"links":1688},[1689,1690,1691,1692,1693,1694,1695,1696,1697],{"id":81,"depth":109,"text":82},{"id":441,"depth":109,"text":442},{"id":497,"depth":109,"text":498},{"id":504,"depth":109,"text":505},{"id":649,"depth":109,"text":649},{"id":784,"depth":109,"text":784},{"id":917,"depth":109,"text":918},{"id":927,"depth":109,"text":928},{"id":1337,"depth":109,"text":1337},"为了实现一个高性能的组件，我们就不能依赖Item的index索引，因为非虚拟化情况下，1000个元素就是1000个延迟加载。我们需要实现的是用户拖动到1000个元素的时候，10个元素使瞬间被渲染出来的。","md",{},true,"\u002Fcsharp\u002Fwpf\u002Fitemscontrol控制子组件的加载",{"description":1698},"csharp\u002FWPF\u002FItemsControl控制子组件的加载","sX5GJYUviYG-A_PWEwWNnWsURxbomp1sKVnuqZwT458",1791042465516]