带缓存的 VirtualizingStackPanel

VirtualizingStackPanel with cache

我们必须要求使用 VirtualizingStackPanel 虚拟化 ListView/ItemsControl。尽管一切都按预期工作,但 Control 的 ItemTemplate 在其初始化阶段坚持使用大量计算的复杂控件 - 这必须在 UI 线程上完成。换句话说,滚动会导致 UI 冻结——如果只需要执行一次就可以了。由于我们不能使用 VirtualizingStackPanel.VirtualizationMode="Recycle"(由于其他一些限制),我们必须尝试一些不同的东西。

我想到了一个“缓存的”virtualizingStackPanel,它实际上并没有处理 ItemTemplate 的模板,而是 'freezes' 控件。当用户滚动回之前加载的模板时,我们可以简单地 'unfreeze' 控件。

可以通过覆盖OnCleanUpVirtualizedItem实现'freeze',如:

    protected override void OnCleanUpVirtualizedItem(CleanUpVirtualizedItemEventArgs args)
    {
        var stuff = FindChild<HeavyStuff>(args.UIElement);

        if (stuff != null)
        {
            int idx = Children.IndexOf(args.UIElement);

            if (!_buffer.ContainsKey(idx))
                _buffer.Add(idx, args.UIElement);

            stuff.Freeze();
            args.Handled = true;
            args.Cancel = true;
        }
        else
        {
            base.OnCleanUpVirtualizedItem(args);
        }
    }

效果很好。该控件保留在 VisualTree 中,它只是 'freezes' 并避免了任何用户输入和可能产生的工作量。但是,当控件重新出现时,我无法弄清楚如何 'unfreeze' 控件。我仔细研究了 reference-source and found the BringIndexIntoView,这可能会像下面这样解决我的问题:

     protected override void BringIndexIntoView(int index)
    {
        if (_buffer.ContainsKey(index))
        {
            FindChild<HeavyStuff>(_buffer[index]).UnFreeze();
        }
        else
        {
            base.BringIndexIntoView(index);
        }
    }

但是,该方法永远不会被内部 VirtualizingStackPanel 逻辑调用。我的第二个想法是覆盖 IItemContainerGenerator,因为生成器确实按需提供 DependencyObjects。但又没有任何运气。不能继承ItemContainerGenerator,因为它是密封的。其次,定义代理并覆盖 ItemContainerGenerator 属性也无济于事,因为基础 class 根本不调用 VirtualizingStackPanel 的 ItemContainerGenerator 属性:

    public new IItemContainerGenerator ItemContainerGenerator => generator;

有没有什么方法可以在控件滚动回视图时获取信息,而无需 VirtualizingStackPanel 重新创建实例?

插件:我还考虑过虚拟化数据源本身。然而,即使我们将数据源虚拟化,全局用户输入也会导致控件执行 CPU 和 UI 线程密集型操作。因此,无论我们选择哪种方式,我们都必须 'freeze' 和 'unfreeze' 某些与视口无关的控件。换句话说,我们仍然需要 UI 虚拟化。

编辑:“冻结”和“解冻”不是指 .NET 对象冻结。我用词不当可能会造成这种混乱。对于“冻结”和“解冻”,我确实指的是一些订阅或取消订阅各种事件处理程序的内部逻辑,这样控件,从视口中发出蜂鸣声,不需要处理该输入。

您可以使用以下扩展 StackPanel 的示例实现来跟踪其托管容器的可见性(根据父滚动查看器的视口)。 只需将自定义 Panel 设置为 ItemsPanelListBox.
重要的是父级 ScrollViewerCanContentScroll 属性 设置为 true(这是 ListBox 的默认值)。

由于 StackPanel 已经实现了 IScrollInfo,因此观察滚动事件和视口非常简单。
添加您的实际实现,以处理更改的容器 and/or 他们的托管模型,到 OnHiddenContainersChanged 方法以完成 Panel.

public class ScrollWatcherPanel : StackPanel
{
  public ScrollWatcherPanel()
  {
    this.Loaded += OnLoaded;
  }

  private void OnLoaded(object sender, RoutedEventArgs e)
  {
    if (!this.ScrollOwner.CanContentScroll)
    {
      throw new InvalidOperationException("ScrollViewer.CanContentScroll must be enabled.");
    }
    this.ScrollOwner.ScrollChanged += OnScrollChanged;
  }

  protected override void OnRenderSizeChanged(SizeChangedInfo sizeInfo)
  {
    base.OnRenderSizeChanged(sizeInfo);
    HandleAllContainers();
  }

  private void OnScrollChanged(object sender, ScrollChangedEventArgs e) 
    => HandleContainerVisibilityChanges((int)e.VerticalChange);

  private void HandleAllContainers()
  {
    int containersBeforeViewportStartCount = (int)this.VerticalOffset;
    int containersBeforeViewportEndCount = containersBeforeViewportStartCount + (int)this.ViewportHeight + 1;

    var newHiddenContainers = new List<FrameworkElement>();
    var newVisibleContainers = new List<FrameworkElement>();
    for (int childContainerIndex = 0; childContainerIndex < this.InternalChildren.Count; childContainerIndex++)
    {
      bool isContainerHiddenBeforeViewport = childContainerIndex < containersBeforeViewportStartCount;
      bool isContainerVisibleInViewport = childContainerIndex < containersBeforeViewportEndCount;

      var childContainer = (FrameworkElement)this.InternalChildren[childContainerIndex];
      if (isContainerHiddenBeforeViewport)
      {
        newHiddenContainers.Add(childContainer);
      }
      else if (isContainerVisibleInViewport)
      {
        newVisibleContainers.Add(childContainer);
      }
      else // Container is hidden after viewport
      {
        newHiddenContainers.Add(childContainer);
      }
    }

    OnHiddenContainersChanged(newHiddenContainers, newVisibleContainers);
  }

  private void HandleContainerVisibilityChanges(int verticalChange)
  {
    int containersBeforeViewportStartCount = (int)this.VerticalOffset;
    int containersBeforeViewportEndCount = containersBeforeViewportStartCount + (int)this.ViewportHeight + 1;
    int newHiddenContainerCount = Math.Abs(verticalChange);
    int newVisibleContainerCount = Math.Abs(verticalChange);
    bool isScrollingDown = verticalChange > 0;
    int changeCount = Math.Abs(verticalChange);

    var newHiddenContainers = new List<FrameworkElement>();
    var newVisibleContainers = new List<FrameworkElement>();
    int changesIndex = Math.Max(0, containersBeforeViewportStartCount - changeCount);
    for (int childContainerIndex = changesIndex; childContainerIndex < this.InternalChildren.Count; childContainerIndex++)
    {
      bool isContainerHiddenBeforeViewport = childContainerIndex < containersBeforeViewportStartCount;
      bool isContainerVisibleInViewport = childContainerIndex < containersBeforeViewportEndCount;

      var childContainer = (FrameworkElement)this.InternalChildren[childContainerIndex];
      if (isContainerHiddenBeforeViewport)
      {
        if (isScrollingDown)
        {
          bool isContainerNewHidden = childContainerIndex >= containersBeforeViewportStartCount - changeCount
            && newHiddenContainerCount > 0;
          if (isContainerNewHidden)
          {
            newHiddenContainers.Add(childContainer);
            newHiddenContainerCount--;
          }
        }
      }
      else if (isContainerVisibleInViewport)
      {
        if (isScrollingDown)
        {
          bool isContainerNewVisible = childContainerIndex >= containersBeforeViewportEndCount - changeCount
            && newVisibleContainerCount > 0;
          if (isContainerNewVisible)
          {
            newVisibleContainers.Add(childContainer);
            newVisibleContainerCount--;
          }
        }
        else
        {
          bool isContainerNewVisible = childContainerIndex >= containersBeforeViewportStartCount
            && newVisibleContainerCount > 0;
          if (isContainerNewVisible)
          {
            newVisibleContainers.Add(childContainer);
            newVisibleContainerCount--;
          }
        }
      }
      else // Container is hidden after viewport (on scroll up)
      {
        if (!isScrollingDown)
        {
          bool isContainerNewHidden = childContainerIndex >= containersBeforeViewportEndCount 
            && newHiddenContainerCount > 0;
          if (isContainerNewHidden)
          {
            newHiddenContainers.Add(childContainer);
            newHiddenContainerCount--;
            if (newHiddenContainerCount == 0)
            {
              break;
            }
          }
        }
      }
    }

    OnHiddenContainersChanged(newHiddenContainers, newVisibleContainers);
  }

  protected virtual void OnHiddenContainersChanged(IEnumerable<FrameworkElement> newHiddenContainers, IEnumerable<FrameworkElement> newVisibleContainers)
  {
    // TODO::Handle "hidden"/"visible" item containers, that are just scrolled out of/into the viewport.
    // You can access the DataContext of the containers to get a reference to the underlying data model.
  }
}