Search code examples
wpfpanelscrollvieweritemscontrol

Custom IScrollInfo panel loses scrolling ability when hosted in an ItemsControl


I'm trying to create a custom panel using the IScrollInfo interface, with little success. I can get it to work if I manually declare items within the custom panel in XAML, but when I put it into an ItemsControl, the scrolling ability stops. I'd really appreciate it if someone would show me where I've gone wrong. Here is the (quite lengthy, but simple) code for the panel:

using IScrollInfoExample.Extentions;
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;

namespace IScrollInfoExample
{
    public class ExampleScrollPanel : Panel, IScrollInfo
    {
        private TranslateTransform _trans = new TranslateTransform();
        private Size _extent = new Size(0, 0);
        private Size _viewport = new Size(0, 0);
        private Point _offset;
        private const double _scrollAmount = 3;

        public ExampleScrollPanel()
        {
            Loaded += ExampleScrollPanel_Loaded;
            RenderTransform = (_trans = new TranslateTransform());
        }

        public bool CanHorizontallyScroll { get; set; } = false;

        public bool CanVerticallyScroll { get; set; } = true;

        public double HorizontalOffset
        {
            get { return _offset.X; }
        }

        public double VerticalOffset
        {
            get { return _offset.Y; }
        }

        public double ExtentHeight
        {
            get { return _extent.Height; }
        }

        public double ExtentWidth
        {
            get { return _extent.Width; }
        }

        public double ViewportHeight
        {
            get { return _viewport.Height; }
        }

        public double ViewportWidth
        {
            get { return _viewport.Width; }
        }

        public ScrollViewer ScrollOwner { get; set; }

        public void LineUp()
        {
            SetVerticalOffset(VerticalOffset - _scrollAmount);
        }

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

        public void MouseWheelUp()
        {
            SetVerticalOffset(VerticalOffset - _scrollAmount);
        }

        public void LineDown()
        {
            SetVerticalOffset(VerticalOffset + _scrollAmount);
        }

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

        public void MouseWheelDown()
        {
            SetVerticalOffset(VerticalOffset + _scrollAmount);
        }

        public void LineLeft()
        {
            SetHorizontalOffset(HorizontalOffset - _scrollAmount);
        }

        public void PageLeft()
        {
            SetHorizontalOffset(HorizontalOffset - _viewport.Width);
        }

        public void MouseWheelLeft()
        {
            LineLeft();
        }

        public void LineRight()
        {
            SetHorizontalOffset(HorizontalOffset + _scrollAmount);
        }

        public void PageRight()
        {
            SetHorizontalOffset(HorizontalOffset + _viewport.Width);
        }

        public void MouseWheelRight()
        {
            LineRight();
        }

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

        public void SetHorizontalOffset(double offset)
        {
            if (offset < 0 || _viewport.Width >= _extent.Width)
            {
                offset = 0;
            }
            else if (offset + _viewport.Width >= _extent.Width)
            {
                offset = _extent.Width - _viewport.Width;
            }
            _offset.X = offset;
            if (ScrollOwner != null) ScrollOwner.InvalidateScrollInfo();
            _trans.X = -offset;
        }

        public void SetVerticalOffset(double offset)
        {
            offset = CoerceVerticalOffset(offset);
            _offset.Y = offset;
            _trans.Y = -offset;
            if (ScrollOwner != null) ScrollOwner.InvalidateScrollInfo();
        }

        private double CoerceVerticalOffset(double offset)
        {
            if (offset < 0 || _viewport.Height >= _extent.Height)
            {
                offset = 0;
            }
            else if (offset + _viewport.Height >= _extent.Height)
            {
                offset = _extent.Height - _viewport.Height;
            }
            return offset;
        }

        private void ExampleScrollPanel_Loaded(object sender, RoutedEventArgs e)
        {
            ScrollViewer scrollOwner = this.GetParentOfType<ScrollViewer>();
            if (scrollOwner != null) ScrollOwner = scrollOwner;
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            UpdateScrollInfo(availableSize);
            Size totalSize = new Size();
            foreach (UIElement child in InternalChildren)
            {
                child.Measure(availableSize);
                totalSize.Height += child.DesiredSize.Height;
                totalSize.Width = Math.Max(totalSize.Width, child.DesiredSize.Width);
            }
            return base.MeasureOverride(totalSize);
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            UpdateScrollInfo(finalSize);
            double verticalPosition = 0;

            int childCount = InternalChildren.Count;
            for (int i = 0; i < childCount; i++)
            {
                UIElement child = InternalChildren[i];
                child.Arrange(new Rect(0, verticalPosition, finalSize.Width, finalSize.Height));
                verticalPosition += child.DesiredSize.Height;
            }
            return base.ArrangeOverride(finalSize);
        }

        private void UpdateScrollInfo(Size availableSize)
        {
            bool viewportChanged = false, extentChanged = false;
            Size extent = CalculateExtent(availableSize);
            if (extent != _extent)
            {
                _extent = extent;
                extentChanged = true;
            }
            if (availableSize != _viewport)
            {
                _viewport = availableSize;
                viewportChanged = true;
            }
            if (extentChanged || viewportChanged) ScrollOwner?.InvalidateScrollInfo();
        }

        private Size CalculateExtent(Size availableSize)
        {
            Size totalExtentSize = new Size();
            foreach (UIElement child in InternalChildren)
            {
                child.Measure(availableSize);
                totalExtentSize.Height += child.DesiredSize.Height;
                totalExtentSize.Width = Math.Max(totalExtentSize.Width, child.DesiredSize.Width);
            }
            return totalExtentSize;
        }
    }
}

Now MainWindow.xaml.cs:

<Window x:Class="IScrollInfoExample.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:Local="clr-namespace:IScrollInfoExample"
    mc:Ignorable="d"
    Title="MainWindow" Height="350" Width="525">
    <ScrollViewer CanContentScroll="True">
        <ItemsControl ItemsSource="{Binding Buttons, RelativeSource={RelativeSource AncestorType={x:Type Local:MainWindow}}}">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <Local:ExampleScrollPanel IsItemsHost="True" />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Button Height="100" Width="250" HorizontalAlignment="Center" Content="{Binding}" />
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>
        <!--<Local:ExampleScrollPanel>
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="A" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="B" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="C" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="D" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="E" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="F" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="G" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="H" />
            <Button Height="100" Width="250" HorizontalAlignment="Center" Content="I" />
        </Local:ExampleScrollPanel>-->
    </ScrollViewer>
</Window>

And the code behind:

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;

namespace IScrollInfoExample
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            Buttons = new ObservableCollection<string>();
            IEnumerable<int> characterCodes = Enumerable.Range(65, 26);
            foreach (int characterCode in characterCodes) Buttons.Add(((char)characterCode).ToString().ToUpper());
        }

        public static readonly DependencyProperty ButtonsProperty = DependencyProperty.Register(nameof(Buttons), typeof(ObservableCollection<string>), typeof(MainWindow), null);

        public ObservableCollection<string> Buttons
        {
            get { return (ObservableCollection<string>)GetValue(ButtonsProperty); }
            set { SetValue(ButtonsProperty, value); }
        }
    }
}

Looking at the XAML first, you can see the manually added Button objects. Run the application and you should see some vertically scrollable buttons in a panel... so far, so good. If you put a breakpoint in the MouseWheelUp or MouseWheelDown methods and scroll, you'll notice that the breakpoint gets hit instantly.

Now if you comment out the lower ExampleScrollPanel with its manually created buttons and uncomment the ItemsControl above, you'll see that the ability to scroll the items has disappeared. My question is "How can I make the scrolling work when the custom panel is hosted within an ItemsControl element?"

Please note that ScrollOwner property is populated using a custom GetParentOfType<T> method, which finds the appropriate ScrollViewer successfully, and is not the cause of this problem. As such, I have not included the VisualTreeHelper-based code for this method.

Also, I noticed that the scroll bars no longer appear once the panel is within the ItemsControl, but I checked and the extent and viewport values of the panel still seem to be getting updated successfully. Any help would be greatly appreciated.


Solution

  • The ItemsControl does not have it's own ScrollViewer and it cannot access the external one that you have provided for that purpose. You need to add the ScrollViewer where the ItemsControl can access it, using the ItemsControl.Template property, like so:

    <ItemsControl ItemsSource="{Binding Buttons, RelativeSource={RelativeSource 
        AncestorType={x:Type Local:MainWindow}}}">
        <ItemsControl.ItemsPanel>
            <ItemsPanelTemplate>
                <Local:ExampleScrollPanel />
            </ItemsPanelTemplate>
        </ItemsControl.ItemsPanel>
        <ItemsControl.Template>
    
            <ControlTemplate TargetType="{x:Type ItemsControl}">
    
                <!--Add the ScrollViewer here, inside the ControlTemplate-->
                <ScrollViewer CanContentScroll="True">
    
                    <!--Your items will be added here-->
                    <ItemsPresenter/> 
    
                </ScrollViewer>
    
            </ControlTemplate>
    
        </ItemsControl.Template>
        <ItemsControl.ItemTemplate>
            <DataTemplate>
                <Button Height="100" Width="250" HorizontalAlignment="Center" 
                    Content="{Binding}" />
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>
    

    Note that you must set the CanContentScroll property of the ScrollViewer to True to inform it that you have implemented the IScrollInfo interface in your panel and want to take over the scrolling function.