【WPF】UI虚拟化之------自定义VirtualizingWrapPanel

简介: 原文:【WPF】UI虚拟化之------自定义VirtualizingWrapPanel 前言 前几天QA报了一个关于OOM的bug,在排查的过程中发现,ListBox控件中被塞入了过多的Item,而ListBox又定义了两种样式的ItemsPanelTemplate。
原文: 【WPF】UI虚拟化之------自定义VirtualizingWrapPanel

前言

前几天QA报了一个关于OOM的bug,在排查的过程中发现,ListBox控件中被塞入了过多的Item,而ListBox又定义了两种样式的ItemsPanelTemplate。一种用的是虚拟化的VirtualizingStackPanel,另一种没有考虑虚拟化用的是WrapPanel。所以当ListBox切换到第二种Template,而且有很多Item的时候,内存就爆掉然后直接挂了。

然后就想着有没有现成的VirtualizingWrapPanel可以直接拿来用用,可惜微软并没有直接给我们提供这种panel,但是提供了VirtualizingPanel这个抽象类。没办法只能自己动手做了,借助于VirtualizingPanelIScrollInfoIScrollInfo主要是用来滚动效果,而VirtualizingPanel则提供了虚拟化过程中,child的移除和添加操作。其实虚拟化的本质不就是把需要显示到UI上的item画上去,把已经画上去但不需要再显示的撤下来嘛!

因为改bug的时候又来了个新的需求,就是要把WrapPanel中每一行的item之间的距离设置为等间距的,所以这次的UI虚拟化之旅确切来说应该是自定义一个VirtualizingUniformGridWrapPanel

实现

1. 新建类VirtualizingWrapPanel,继承VirtualizingPanel并实现IScrollInfo

public class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
{
}

然后添加一个TranslateTransform字段,这主要是滚动时需要用到。

private TranslateTransform trans = new TranslateTransform();

接下来添加几个依赖属性,设置内部Child的宽、高和鼠标滚动一次的偏移量。

public static readonly DependencyProperty ChildWidthProperty = DependencyProperty.RegisterAttached("ChildWidth", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));

public static readonly DependencyProperty ChildHeightProperty = DependencyProperty.RegisterAttached("ChildHeight", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));

//鼠标每一次滚动 UI上的偏移
public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.RegisterAttached("ScrollOffset", typeof(int), typeof(VirtualizingWrapPanel), new PropertyMetadata(10));

public int ScrollOffset
{
     get { return Convert.ToInt32(GetValue(ScrollOffsetProperty)); }
     set { SetValue(ScrollOffsetProperty, value); }
}
public double ChildWidth
{
     get => Convert.ToDouble(GetValue(ChildWidthProperty));
     set => SetValue(ChildWidthProperty, value);
}
public double ChildHeight
{
     get => Convert.ToDouble(GetValue(ChildHeightProperty));
     set => SetValue(ChildHeightProperty, value);
}

2. 理解WPF中的布局定位流程

WPF中布局定位的计算是通过Measure和Arrange方法构成的。以VirtualizingWrapPanel为例(以下简称VWP),VWP的父layout调用自身的Measure(Size availableSize) 方法,告诉VWP你有availableSize的大小可以使用,然后MeasureCore会根据一定的测量逻辑,告诉VWP的protected override Size MeasureOverride(Size availableSize) 方法,你有availableSize的大小可以用,在这里VWP调用其子元素的Measure方法,告诉子元素有多大的Size可以用(此例,因为我们子child的大小都是通过依赖属性设置好的,所以直接传入即可,子child的DesiredSize也不考虑)。子child都Measure完之后,返回一个Size,这个Size是VMP自身需要的Size,父layout会通过VWP.DesiredSize属性拿到这个值。然后ArrangeCore又会根据一定的逻辑,分配一个finalSize给VWP。VWP通过protected override Size ArrangeOverride(Size finalSize)方法就收到了这个值,然后在给定的finalSize里划分不同的区域,调用子child的Arrange方法,告诉每个child应该在哪个区域。

 /// <summary>
        /// scroll/availableSize/添加删除元素 改变都会触发  edit元素不会改变
        /// </summary>
        /// <param name="availableSize"></param>
        /// <returns></returns>
        protected override Size MeasureOverride(Size availableSize)
        {
            this.UpdateScrollInfo(availableSize);//availableSize更新后,更新滚动条
            int firstVisiableIndex = 0, lastVisiableIndex = 0;
            //availableSize更新后,获取当前viewport内可放置的item的开始和结束索引,
            //firstIdnex-lastIndex之间的item可能部分在viewport中也可能都不在viewport中。
            GetVisiableRange(ref firstVisiableIndex, ref lastVisiableIndex);

            //因为配置了虚拟化,所以children的个数一直是viewport区域内的个数,
            //如果没有虚拟化则是ItemSource的整个的个数
            UIElementCollection children = this.InternalChildren;
            IItemContainerGenerator generator = this.ItemContainerGenerator;
            //获得第一个可被显示的item的位置
            GeneratorPosition startPosi = generator.GeneratorPositionFromIndex(firstVisiableIndex);
            int childIndex = (startPosi.Offset == 0) ? startPosi.Index : startPosi.Index + 1;//startPosi在chilren中的索引
            using (generator.StartAt(startPosi, GeneratorDirection.Forward, true))
            {
                int itemIndex = firstVisiableIndex;
                //生成lastVisiableIndex-firstVisiableIndex个item
                while (itemIndex <= lastVisiableIndex)
                {
                    bool newlyRealized = false;
                    var child = generator.GenerateNext(out newlyRealized) as UIElement;
                    if (newlyRealized)
                    {
                        if (childIndex >= children.Count)
                            base.AddInternalChild(child);
                        else
                        {
                            base.InsertInternalChild(childIndex, child);
                        }
                        generator.PrepareItemContainer(child);
                    }
                    else
                    {
                        if (!child.Equals(children[childIndex]))
                        {
                            base.RemoveInternalChildRange(childIndex, 1);
                        }
                    }
                    child.Measure(new Size(this.ChildWidth, this.ChildHeight));
                    //child.DesiredSize//child想要的size
                    itemIndex++;
                    childIndex++;
                }
            }
            CleanUpItems(firstVisiableIndex, lastVisiableIndex);
            return new Size(double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height);//自身想要的size
        }
        protected override Size ArrangeOverride(Size finalSize)
        {
            Debug.WriteLine("----ArrangeOverride");
            var generator = this.ItemContainerGenerator;
            UpdateScrollInfo(finalSize);
            int childPerRow = CalculateChildrenPerRow(finalSize);
            double availableItemWidth = finalSize.Width / childPerRow;
            for (int i = 0; i <= this.Children.Count - 1; i++)
            {
                var child = this.Children[i];
                int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0));
                int row = itemIndex / childPerRow;//current row
                int column = itemIndex % childPerRow;
                double xCorrdForItem = 0;

                xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2;

                Rect rec = new Rect(xCorrdForItem, row * this.ChildHeight, this.ChildWidth, this.ChildHeight);
                child.Arrange(rec);
            }
            return finalSize;
        }

3. 什么时候应该刷新UI

MSDN文档 告诉我们,当ScrollViewer的offset, extent, or viewport 这三个属性发生变化时,应该当调用ScrollViewer的InvalidateScrollInfo方法,然后ScrollView就会自动更新滚动条长短和位置。此时也应该调用InvalidateMeasure方法,然后会重新Measure布局。
offset,extent和viewport的表示区域如下图:
这里写图片描述
黑色的表示实际显示到界面上的内容。如果不虚拟化则24个item都会在wrappanel中,虚拟化后只有需要显示的那部分(9-16)会在wrappanel中,其他的都删除了。

更新UI操作:

public void SetVerticalOffset(double offset)
        {
            if (offset < 0 || this.viewPort.Height >= this.extent.Height)
                offset = 0;
            else
                if (offset + this.viewPort.Height >= this.extent.Height)
                offset = this.extent.Height - this.viewPort.Height;

            this.offset.Y = offset;
            this.ScrollOwner?.InvalidateScrollInfo();//Scroll信息已过期
            this.trans.Y = -offset;
            this.InvalidateMeasure();//Measure信息已过期
            //接下来会触发MeasureOverride()
        }

4. 虚拟化操作

操作的第一步是获取当前VWP中已加载的所有的child和ListBox的数据源中所有的child。
VWP中children的获取可通过this.InternalChildren拿到。
数据源中children包含在this.ItemContainerGenerator 里面。
这里sdk有个bug,如果你不先调用this.InternalChildren,直接用ItemContainerGenerator后续生成child操作会返回null。

第二步,获取到应该显示到viewport区域内的第一个child和最后一个child的索引,此时viewport的大小可能已经是变化后的。(因为你可能滚动了鼠标,或者更改了VWP的宽高)

        /// <summary>
        /// 获取所有item,在可视区域内第一个item和最后一个item的索引
        /// </summary>
        /// <param name="firstIndex"></param>
        /// <param name="lastIndex"></param>
        void GetVisiableRange(ref int firstIndex, ref int lastIndex)
        {
            int childPerRow = CalculateChildrenPerRow(this.extent);
            firstIndex = Convert.ToInt32(Math.Floor(this.offset.Y / this.ChildHeight)) * childPerRow;
            lastIndex = Convert.ToInt32(Math.Ceiling((this.offset.Y + this.viewPort.Height) / this.ChildHeight)) * childPerRow - 1;
            int itemsCount = GetItemCount(this);
            if (lastIndex >= itemsCount)
                lastIndex = itemsCount - 1;

        }

第三、通过Generator生成从firstIndex到lastIndex的项,并添加到ListBox(等ItemsControl中)。
在开始之前先看几个定义和工作流程:

  • 定义:在这里我们把你绑定到ItemsControl上的数据称作DataItems,其子项称为DataItem。
  • 定义:把在UI上的Itemscontrol空间内的item称为UIItem,UIItem放到不同的control里有不同的名字,如ListViewItemTreeViewItem等。
  • 获取generatorPostition流程:对应的是generator.GeneratorPositionFromIndex(dataItemIndex)方法,根据dataItemIndex从DataItems里找到对应的DataItem,然后再根据DataItem获取到它在generator里的索引。
  • GeneratorPosition类:有两个属性index和offset。当offset==0时,表示此DataItem被Realized过,即存在对应的UIItem,index就是UIItem在generator中的位置索引。当offset!=0时,表示此DataItem是Virtualized的,没有被Realized过,此时index是-1。(不知道理解是否有误)
  • 生成流程:generator负责把DataItem加工成UIItem并显示(添加)到ItemsControl上。对应的是generator.GenerateNext()方法,返回值是一个UIElement(也就是UIItem),并把它添加/插入到Children中,并为它准备好容器PrepareItemContainer
  • 其他:listBox.ItemContainerGenerator几个方法的对比:
    • ListBox.ItemContainerGenerator.ContainerFromIndex():通过DataItem在DataItems里的index,查到在ItemContainerGenerator中对应的UIItem。
    • ListBox.ItemContainerGenerator.ContainerFromItem(): 通过DataItem,查找在ItemContainerGenerator中对应的Item。
    • generator.GeneratorPositionFromIndex():通过DataItem在DataItems里的index,获取它在generator里的位置。
 GeneratorPosition startPosi = generator.GeneratorPositionFromIndex(firstVisiableIndex);
            int childIndex = (startPosi.Offset == 0) ? startPosi.Index : startPosi.Index + 1;
            using (generator.StartAt(startPosi, GeneratorDirection.Forward, true))
            {
                int itemIndex = firstVisiableIndex;
                while (itemIndex <= lastVisiableIndex)
                {
                    bool newlyRealized = false;
                    //不断的从DataItems中获取DataItem并生成UIItem
                    var child = generator.GenerateNext(out newlyRealized) as UIElement;
                    if (newlyRealized)
                    {
                        if (childIndex >= children.Count)
                        {
                            base.AddInternalChild(child);
                        }
                        else
                        {
                            base.InsertInternalChild(childIndex, child);
                        }
                        generator.PrepareItemContainer(child);
                    }
                    else
                    {//generator里已经有了
                        if (!child.Equals(children[childIndex]))
                        {
                        //不相等表示children[childIndex]对应的DataItem已经不在DataItems里了,所以在Children里也要删除。
                            base.RemoveInternalChildRange(childIndex, 1);
                        }
                    }
                    child.Measure(new Size(this.ChildWidth, this.ChildHeight));
                    itemIndex++;
                    childIndex++;
                }
            }

第四、将VWP中已不需在viewport内显示的child从children和generator的container中移除。(generator里的子项个数就是children里的子项个数。)

        /// <summary>
        /// 将不在可视区域内的item 移除
        /// </summary>
        /// <param name="startIndex">可视区域开始索引</param>
        /// <param name="endIndex">可视区域结束索引</param>
        void CleanUpItems(int startIndex, int endIndex)
        {
            var children = this.InternalChildren;
            var generator = this.ItemContainerGenerator;
            for (int i = children.Count - 1; i >= 0; i--)
            {
                var childGeneratorPosi = new GeneratorPosition(i, 0);
                int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPosi);

                if (itemIndex < startIndex || itemIndex > endIndex)
                {

                    generator.Remove(childGeneratorPosi, 1);
                    RemoveInternalChildRange(i, 1);
                }
            }
        }

这里虽然调用了generator.Remove方法,将不需要显示的进行移除,但是我发现移除后generator中元素的个数与ListBox绑定的数据源中元素的个数始终是一致的。所以我觉得可能是将次child从generator的container中移除了。因为你从上面的MeasureOverride方法中也看到了,新加时是调用generator的PrepareItemContainer方法。

5. UniformGrid效果的WrapPanel该怎么给child Arrange

其实就在上面的ArrangeOverride方法里,很简单:

 xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2;

6. 存在的Bug或者问题

第一个bug你已经在上面的第4步看过了。
还有个bug是VMP的ScrollOwner报null reference的异常,本质是找不到包裹它的ScrollViewer,其原因可能有两个:
1. 为你的ListBox设置ItemsPanlTempalte是通过Bind的方式,会引发这个异常。如果你尝试着给它Bind一个scrollviewer,那么接下来你可能还会面临着滚动页面出现空白,但是明明新的child已经生成了,就是不会显示的UI上的问题。正确的解决方式是CodeBind,用C#代码为这个ListBox设置ItemsPanlTemplate
2. 自定义了ControlTempalte,就像下面这个代码一样:

<ControlTemplate TargetType="{x:Type ListView}">
        <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Visible">
            <Border Margin="10">
                  <ItemsPresenter   />
            </Border>
        </ScrollViewer>
</ControlTemplate>

相当于你定义ControlTempate的时候重新设置了ScrollViewer,然后VMP就找不到了。所以看能不能找到不定义Tempalte的方法吧。

7. 使用方法

<ListBox Margin="0,50,0,0" Name="listB">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <TextBlock Text="{Binding}" Width="70" Height="70"/>
                </DataTemplate>
            </ListBox.ItemTemplate>
            <ListBox.ItemsPanel>
                <ItemsPanelTemplate>
                    <local:VirtualizingWrapPanel ScrollOffset="50" ChildHeight="70" ChildWidth="70"/>
                </ItemsPanelTemplate>
            </ListBox.ItemsPanel>
        </ListBox>

8. 完整代码:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;

namespace VirtualizingPPanel
{

    public class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
    {
        private TranslateTransform trans = new TranslateTransform();

        public VirtualizingWrapPanel()
        {
            this.RenderTransform = trans;
        }

        #region DependencyProperties
        public static readonly DependencyProperty ChildWidthProperty = DependencyProperty.RegisterAttached("ChildWidth", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));

        public static readonly DependencyProperty ChildHeightProperty = DependencyProperty.RegisterAttached("ChildHeight", typeof(double), typeof(VirtualizingWrapPanel), new FrameworkPropertyMetadata(200.0, FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsArrange));

        //鼠标每一次滚动 UI上的偏移
        public static readonly DependencyProperty ScrollOffsetProperty = DependencyProperty.RegisterAttached("ScrollOffset", typeof(int), typeof(VirtualizingWrapPanel), new PropertyMetadata(10));

        public int ScrollOffset
        {
            get { return Convert.ToInt32(GetValue(ScrollOffsetProperty)); }
            set { SetValue(ScrollOffsetProperty, value); }
        }
        public double ChildWidth
        {
            get => Convert.ToDouble(GetValue(ChildWidthProperty));
            set => SetValue(ChildWidthProperty, value);
        }
        public double ChildHeight
        {
            get => Convert.ToDouble(GetValue(ChildHeightProperty));
            set => SetValue(ChildHeightProperty, value);
        }
        #endregion

        int GetItemCount(DependencyObject element)
        {
            var itemsControl = ItemsControl.GetItemsOwner(element);
            return itemsControl.HasItems ? itemsControl.Items.Count : 0;
        }
        int CalculateChildrenPerRow(Size availableSize)
        {
            int childPerRow = 0;
            if (availableSize.Width == double.PositiveInfinity)
                childPerRow = this.Children.Count;
            else
                childPerRow = Math.Max(1, Convert.ToInt32(Math.Floor(availableSize.Width / this.ChildWidth)));
            return childPerRow;
        }
        /// <summary>
        /// width不超过availableSize的情况下,自身实际需要的Size(高度可能会超出availableSize)
        /// </summary>
        /// <param name="availableSize"></param>
        /// <param name="itemsCount"></param>
        /// <returns></returns>
        Size CalculateExtent(Size availableSize, int itemsCount)
        {
            int childPerRow = CalculateChildrenPerRow(availableSize);//现有宽度下 一行可以最多容纳多少个
            return new Size(childPerRow * this.ChildWidth, this.ChildHeight * Math.Ceiling(Convert.ToDouble(itemsCount) / childPerRow));
        }
        /// <summary>
        /// 更新滚动条
        /// </summary>
        /// <param name="availableSize"></param>
        void UpdateScrollInfo(Size availableSize)
        {
            var extent = CalculateExtent(availableSize, GetItemCount(this));//extent 自己实际需要
            if (extent != this.extent)
            {
                this.extent = extent;
                this.ScrollOwner.InvalidateScrollInfo();
            }
            if (availableSize != this.viewPort)
            {
                this.viewPort = availableSize;
                this.ScrollOwner.InvalidateScrollInfo();
            }
        }
        /// <summary>
        /// 获取所有item,在可视区域内第一个item和最后一个item的索引
        /// </summary>
        /// <param name="firstIndex"></param>
        /// <param name="lastIndex"></param>
        void GetVisiableRange(ref int firstIndex, ref int lastIndex)
        {
            int childPerRow = CalculateChildrenPerRow(this.extent);
            firstIndex = Convert.ToInt32(Math.Floor(this.offset.Y / this.ChildHeight)) * childPerRow;
            lastIndex = Convert.ToInt32(Math.Ceiling((this.offset.Y + this.viewPort.Height) / this.ChildHeight)) * childPerRow - 1;
            int itemsCount = GetItemCount(this);
            if (lastIndex >= itemsCount)
                lastIndex = itemsCount - 1;

        }
        /// <summary>
        /// 将不在可视区域内的item 移除
        /// </summary>
        /// <param name="startIndex">可视区域开始索引</param>
        /// <param name="endIndex">可视区域结束索引</param>
        void CleanUpItems(int startIndex, int endIndex)
        {
            var children = this.InternalChildren;
            var generator = this.ItemContainerGenerator;
            for (int i = children.Count - 1; i >= 0; i--)
            {
                var childGeneratorPosi = new GeneratorPosition(i, 0);
                int itemIndex = generator.IndexFromGeneratorPosition(childGeneratorPosi);

                if (itemIndex < startIndex || itemIndex > endIndex)
                {

                    generator.Remove(childGeneratorPosi, 1);
                    RemoveInternalChildRange(i, 1);
                }
            }
        }
        /// <summary>
        /// scroll/availableSize/添加删除元素 改变都会触发  edit元素不会改变
        /// </summary>
        /// <param name="availableSize"></param>
        /// <returns></returns>
        protected override Size MeasureOverride(Size availableSize)
        {
            this.UpdateScrollInfo(availableSize);//availableSize更新后,更新滚动条
            int firstVisiableIndex = 0, lastVisiableIndex = 0;
            GetVisiableRange(ref firstVisiableIndex, ref lastVisiableIndex);//availableSize更新后,获取当前viewport内可放置的item的开始和结束索引  firstIdnex-lastIndex之间的item可能部分在viewport中也可能都不在viewport中。

            UIElementCollection children = this.InternalChildren;//因为配置了虚拟化,所以children的个数一直是viewport区域内的个数,如果没有虚拟化则是ItemSource的整个的个数
            IItemContainerGenerator generator = this.ItemContainerGenerator;
            //获得第一个可被显示的item的位置
            GeneratorPosition startPosi = generator.GeneratorPositionFromIndex(firstVisiableIndex);
            int childIndex = (startPosi.Offset == 0) ? startPosi.Index : startPosi.Index + 1;//startPosi在chilren中的索引
            using (generator.StartAt(startPosi, GeneratorDirection.Forward, true))
            {
                int itemIndex = firstVisiableIndex;
                while (itemIndex <= lastVisiableIndex)//生成lastVisiableIndex-firstVisiableIndex个item
                {
                    bool newlyRealized = false;
                    var child = generator.GenerateNext(out newlyRealized) as UIElement;
                    if (newlyRealized)
                    {
                        if (childIndex >= children.Count)
                            base.AddInternalChild(child);
                        else
                        {
                            base.InsertInternalChild(childIndex, child);
                        }
                        generator.PrepareItemContainer(child);
                    }
                    else
                    {
                        //处理 正在显示的child被移除了这种情况
                        if (!child.Equals(children[childIndex]))
                        {
                            base.RemoveInternalChildRange(childIndex, 1);
                        }
                    }
                    child.Measure(new Size(this.ChildWidth, this.ChildHeight));
                    //child.DesiredSize//child想要的size
                    itemIndex++;
                    childIndex++;
                }
            }
            CleanUpItems(firstVisiableIndex, lastVisiableIndex);
            return new Size(double.IsInfinity(availableSize.Width) ? 0 : availableSize.Width, double.IsInfinity(availableSize.Height) ? 0 : availableSize.Height);//自身想要的size
        }
        protected override Size ArrangeOverride(Size finalSize)
        {
            Debug.WriteLine("----ArrangeOverride");
            var generator = this.ItemContainerGenerator;
            UpdateScrollInfo(finalSize);
            int childPerRow = CalculateChildrenPerRow(finalSize);
            double availableItemWidth = finalSize.Width / childPerRow;
            for (int i = 0; i <= this.Children.Count - 1; i++)
            {
                var child = this.Children[i];
                int itemIndex = generator.IndexFromGeneratorPosition(new GeneratorPosition(i, 0));
                int row = itemIndex / childPerRow;//current row
                int column = itemIndex % childPerRow;
                double xCorrdForItem = 0;

                xCorrdForItem = column * availableItemWidth + (availableItemWidth - this.ChildWidth) / 2;

                Rect rec = new Rect(xCorrdForItem, row * this.ChildHeight, this.ChildWidth, this.ChildHeight);
                child.Arrange(rec);
            }
            return finalSize;
        }
        protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
        {
            base.OnRenderSizeChanged(sizeInfo);
            this.SetVerticalOffset(this.VerticalOffset);
        }
        protected override void OnClearChildren()
        {
            base.OnClearChildren();
            this.SetVerticalOffset(0);
        }
        protected override void BringIndexIntoView(int index)
        {
            if (index < 0 || index >= Children.Count)
                throw new ArgumentOutOfRangeException();
            int row = index / CalculateChildrenPerRow(RenderSize);
            SetVerticalOffset(row * this.ChildHeight);
        }
        #region IScrollInfo Interface
        public bool CanVerticallyScroll { get; set; }
        public bool CanHorizontallyScroll { get; set; }

        private Size extent = new Size(0, 0);
        public double ExtentWidth => this.extent.Width;

        public double ExtentHeight => this.extent.Height;

        private Size viewPort = new Size(0, 0);
        public double ViewportWidth => this.viewPort.Width;

        public double ViewportHeight => this.viewPort.Height;

        private Point offset;
        public double HorizontalOffset => this.offset.X;

        public double VerticalOffset => this.offset.Y;

        public ScrollViewer ScrollOwner { get; set; }

        public void LineDown()
        {
            this.SetVerticalOffset(this.VerticalOffset + this.ScrollOffset);
        }

        public void LineLeft()
        {
            throw new NotImplementedException();
        }

        public void LineRight()
        {
            throw new NotImplementedException();
        }

        public void LineUp()
        {
            this.SetVerticalOffset(this.VerticalOffset - this.ScrollOffset);
        }

        public Rect MakeVisible(Visual visual, Rect rectangle)
        {
            return new Rect();
        }

        public void MouseWheelDown()
        {
            this.SetVerticalOffset(this.VerticalOffset + this.ScrollOffset);
        }

        public void MouseWheelLeft()
        {
            throw new NotImplementedException();
        }

        public void MouseWheelRight()
        {
            throw new NotImplementedException();
        }

        public void MouseWheelUp()
        {
            this.SetVerticalOffset(this.VerticalOffset - this.ScrollOffset);
        }

        public void PageDown()
        {
            this.SetVerticalOffset(this.VerticalOffset + this.viewPort.Height);
        }

        public void PageLeft()
        {
            throw new NotImplementedException();
        }

        public void PageRight()
        {
            throw new NotImplementedException();
        }

        public void PageUp()
        {
            this.SetVerticalOffset(this.VerticalOffset - this.viewPort.Height);
        }

        public void SetHorizontalOffset(double offset)
        {
            throw new NotImplementedException();
        }

        public void SetVerticalOffset(double offset)
        {
            if (offset < 0 || this.viewPort.Height >= this.extent.Height)
                offset = 0;
            else
                if (offset + this.viewPort.Height >= this.extent.Height)
                offset = this.extent.Height - this.viewPort.Height;

            this.offset.Y = offset;
            this.ScrollOwner?.InvalidateScrollInfo();
            this.trans.Y = -offset;
            this.InvalidateMeasure();
            //接下来会触发MeasureOverride()
        }
        #endregion
    }
}

9. Demo下载

最终效果:
这里写图片描述

下载:
链接: https://pan.baidu.com/s/1jHMBFM2 密码: 4csp

参考

1. Magentaize!——正确实现 WPF 中的 UI 虚拟化
2. GitHub - digimezzo/WPFControls: WPF Controls
3.WPF布局
4.Implementing a VirtualizingPanel part 3: MeasureCore

目录
相关文章
|
2天前
「Mac畅玩鸿蒙与硬件46」UI互动应用篇23 - 自定义天气预报组件
本篇将带你实现一个自定义天气预报组件。用户可以通过选择不同城市来获取相应的天气信息,页面会显示当前城市的天气图标、温度及天气描述。这一功能适合用于动态展示天气信息的小型应用。
65 38
「Mac畅玩鸿蒙与硬件46」UI互动应用篇23 - 自定义天气预报组件
|
29天前
|
前端开发 搜索推荐 开发者
「Mac畅玩鸿蒙与硬件20」鸿蒙UI组件篇10 - Canvas 组件自定义绘图
Canvas 组件在鸿蒙应用中用于绘制自定义图形,提供丰富的绘制功能和灵活的定制能力。通过 Canvas,可以创建矩形、圆形、路径、文本等基础图形,为鸿蒙应用增添个性化的视觉效果。本篇将介绍 Canvas 组件的基础操作,涵盖绘制矩形、圆形、路径和文本的实例。
63 12
「Mac畅玩鸿蒙与硬件20」鸿蒙UI组件篇10 - Canvas 组件自定义绘图
|
29天前
|
搜索推荐 前端开发 开发者
「Mac畅玩鸿蒙与硬件19」鸿蒙UI组件篇9 - 自定义动画实现
自定义动画让开发者可以设计更加个性化和复杂的动画效果,适合表现独特的界面元素。鸿蒙提供了丰富的工具,支持通过自定义路径和时间控制来创建复杂的动画运动。本篇将带你学习如何通过自定义动画实现更多样化的效果。
72 11
「Mac畅玩鸿蒙与硬件19」鸿蒙UI组件篇9 - 自定义动画实现
|
25天前
|
UED
「Mac畅玩鸿蒙与硬件31」UI互动应用篇8 - 自定义评分星级组件
本篇将带你实现一个自定义评分星级组件,用户可以通过点击星星进行评分,并实时显示评分结果。为了让界面更具吸引力,我们还将添加一只小猫图片作为评分的背景装饰。
63 6
「Mac畅玩鸿蒙与硬件31」UI互动应用篇8 - 自定义评分星级组件
|
27天前
|
前端开发 开发者
「Mac畅玩鸿蒙与硬件23」鸿蒙UI组件篇13 - 自定义组件的创建与使用
自定义组件可以帮助开发者实现复用性强、逻辑清晰的界面模块。通过自定义组件,鸿蒙应用能够提高代码的可维护性,并简化复杂布局的构建。本篇将介绍如何创建自定义组件,如何向组件传递数据,以及如何在不同页面间复用这些组件。
36 5
「Mac畅玩鸿蒙与硬件23」鸿蒙UI组件篇13 - 自定义组件的创建与使用
|
2月前
|
API UED 容器
深入探索 Element UI:自定义滚动条与弹出层管理的技巧
在这篇博客中,我们将深入探讨 Element UI 中的自定义滚动条及弹出层管理技巧。文章详细介绍了 el-scrollbar 组件的使用和参数设置,以及 PopupManager 如何有效管理弹出层的 z-index。我们还将探讨如何实现灵活的全屏组件,利用 vue-popper 创建自定义弹出层,最后介绍 ClickOutside 指令的用法。这些高级技巧将帮助你提升 Element UI 应用程序的用户体验与交互灵活性。
291 1
深入探索 Element UI:自定义滚动条与弹出层管理的技巧
|
3月前
|
搜索推荐 前端开发 C#
推荐7款美观且功能强大的WPF UI库
推荐7款美观且功能强大的WPF UI库
146 2
|
4月前
|
C# 开发者 Windows
一款基于Fluent设计风格、现代化的WPF UI控件库
一款基于Fluent设计风格、现代化的WPF UI控件库
113 1
|
4月前
|
vr&ar C# 图形学
WPF与AR/VR的激情碰撞:解锁Windows Presentation Foundation应用新维度,探索增强现实与虚拟现实技术在现代UI设计中的无限可能与实战应用详解
【8月更文挑战第31天】增强现实(AR)与虚拟现实(VR)技术正迅速改变生活和工作方式,在游戏、教育及工业等领域展现出广泛应用前景。本文探讨如何在Windows Presentation Foundation(WPF)环境中实现AR/VR功能,通过具体示例代码展示整合过程。尽管WPF本身不直接支持AR/VR,但借助第三方库如Unity、Vuforia或OpenVR,可实现沉浸式体验。例如,通过Unity和Vuforia在WPF中创建AR应用,或利用OpenVR在WPF中集成VR功能,从而提升用户体验并拓展应用功能边界。
86 0
|
4月前
|
C# 开发者 设计模式
WPF开发者必读:命令模式应用秘籍,轻松简化UI与业务逻辑交互,让你的代码更上一层楼!
【8月更文挑战第31天】在WPF应用开发中,命令模式是简化UI与业务逻辑交互的关键技术,通过将请求封装为对象,实现UI操作与业务逻辑分离,便于代码维护与扩展。本文介绍命令模式的概念及实现方法,包括使用`ICommand`接口、`RelayCommand`类及自定义命令等方式,并提供示例代码展示如何在项目中应用命令模式。
56 0