wpfscrollviewer

WPF Nested ScrollViewers with different PanningModes?


I'm trying to create a touchscreen interface similar to the macOS Finder's column view, which is a series of horizontally-stacked lists where each list is individually scrollable (vertically) and the whole thing is scrollable horizontally, like this:

os x finder column view

Here is my .NET 4.6.1 "minimum viable code sample" to demonstrate what I'm doing:

Front end:

<Window x:Class="TestNestedScroll.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="clr-namespace:TestNestedScroll"
    Title="MainWindow" Height="500" Width="800"
    DataContext="{Binding RelativeSource={RelativeSource Self}}">

    <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" PanningMode="HorizontalOnly">
        <ItemsControl ItemsSource="{Binding Columns}">
            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <StackPanel Orientation="Horizontal"/>
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" PanningMode="VerticalOnly">
                        <ItemsControl ItemsSource="{Binding Rows}">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Rectangle Width="300" Height="100" Fill="Purple" Margin="20"/>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </ScrollViewer>
                </DataTemplate>
            </ItemsControl.ItemTemplate>
        </ItemsControl>

    </ScrollViewer>
</Window>

Back end:

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

namespace TestNestedScroll
{
    public partial class MainWindow : Window
    {
        public class Row {}

        public class Column { public List<Row> Rows { get; } = Enumerable.Repeat( new Row(), 20 ).ToList(); }

        public List<Column> Columns { get; } = Enumerable.Repeat( new Column(), 10 ).ToList();

        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

Right now I can only get this to work one way -- either I turn off PanningMode on the inner scroll viewers and I can scroll the outer ScrollViewer left and right, or I set PanningMode="VerticalOnly" (or Both, or VerticalFirst, doesn't matter) on the inner scroll viewers and they become individually vertically scrollable, but the horizontal ScrollViewer stops working.

Is there a way to make this work? Perhaps the horizontal touch events on the inner ScrollViewers have to be caught and manually bubbled up to the parent ScrollViewer somehow -- how would I do that?


Solution

  • I have a solution for you with a little bug. You have to "Touch Up" to switch the PanningMode. Maybe you can find the bug that it's working without "Touch Up" again.


    After changing the PanningMode of the parent ScrollViewer, the Touch events were not routed anymore to the inner child ScrollViewer. So I have also tried to route the touch events back to the parent ScrollViewer. Maybe I have just an error in my logic.

    <ScrollViewer x:Name="Daddy" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled" PanningMode="HorizontalOnly">
            <ItemsControl ItemsSource="{Binding Columns}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate>
                        <WrapPanel />
                    </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" PanningMode="VerticalOnly">
                            <ItemsControl ItemsSource="{Binding Rows}">
                                <ItemsControl.ItemTemplate>
                                    <DataTemplate>
                                        <Rectangle Width="300" Height="100" Fill="Purple" Margin="20"/>
                                    </DataTemplate>
                                </ItemsControl.ItemTemplate>
                            </ItemsControl>
                            <i:Interaction.Behaviors>
                                <local:BubbleTouch ParentElement="{Binding ElementName=Daddy}"/>
                            </i:Interaction.Behaviors>
                        </ScrollViewer>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </ScrollViewer>
    

    public class BubbleTouch : Behavior<ScrollViewer>
    {
        public ScrollViewer ParentElement
        {
            get => (ScrollViewer) GetValue(ParentElementProperty);
            set => SetValue(ParentElementProperty, value);
        }
    
        /// <summary>
        /// The <see cref="ParentElement"/> DependencyProperty.
        /// </summary>
        public static readonly DependencyProperty ParentElementProperty = DependencyProperty.Register("ParentElement", typeof(ScrollViewer), typeof(BubbleTouch), new PropertyMetadata(null));
    
        private Brush _DefaultBrush;
    
        protected override void OnAttached()
        {
            base.OnAttached();
            AssociatedObject.TouchMove += _ChildMove;
            AssociatedObject.TouchDown += _ChildDown;
            AssociatedObject.TouchUp += _ChildUp;
            ParentElement.TouchMove += _ParentMove;
            ParentElement.TouchDown += _ParentDown;
            ParentElement.TouchUp += _ParentUp;
        }
    
        protected override void OnDetaching()
        {
            AssociatedObject.TouchMove -= _ChildMove;
            AssociatedObject.TouchDown -= _ChildDown;
            AssociatedObject.TouchUp -= _ChildUp;
            base.OnDetaching();
        }
    
        private TouchPoint _ParentStartPosition;
        private bool _ParentTouchDown;
        private bool _ParentMoving;
    
        private void _ParentDown(object sender, TouchEventArgs e)
        {
            _ParentTouchDown = true;
            _ParentStartPosition = e.GetTouchPoint(Application.Current.MainWindow);
        }
    
        private void _ParentMove(object sender, TouchEventArgs e)
        {
            if (_ParentTouchDown && !_ParentMoving)
            {
                double deltaX = _ParentStartPosition.Bounds.X - e.GetTouchPoint(Application.Current.MainWindow).Bounds.X;
                double deltaY = _ParentStartPosition.Bounds.Y - e.GetTouchPoint(Application.Current.MainWindow).Bounds.Y;
    
                Trace.WriteLine($"{deltaX} | {deltaY}");
    
                if (deltaX > deltaY && deltaX > 5)
                {
                    AssociatedObject.PanningMode = PanningMode.None;
                    AssociatedObject.Background = Brushes.Aqua;
                    ParentElement.PanningMode = PanningMode.HorizontalOnly;
                    _ParentMoving = true;
                }
                else if (deltaY > deltaX && deltaY > 5)
                {
                    AssociatedObject.PanningMode = PanningMode.VerticalOnly;
                    AssociatedObject.Background = Brushes.ForestGreen;
                    ParentElement.PanningMode = PanningMode.HorizontalOnly;
                    _ParentMoving = true;
                }
            }
        }
        
        private void _ParentUp(object sender, TouchEventArgs e)
        {
            _ParentTouchDown = false;
            _ParentMoving = false;
            AssociatedObject.Background = _DefaultBrush;
        }
        
        private TouchPoint _ChildStartPosition;
        private bool _ChildTouchDown;
        private bool _ChildMoving;
    
        private void _ChildDown(object sender, TouchEventArgs e)
        {
            _DefaultBrush = AssociatedObject.Background;
            _ChildTouchDown = true;
            _ChildStartPosition = e.GetTouchPoint(Application.Current.MainWindow);
        }
    
        private void _ChildMove(object sender, TouchEventArgs e)
        {
            if (_ChildTouchDown && !_ChildMoving)
            {
                double deltaX = _ChildStartPosition.Bounds.X - e.GetTouchPoint(Application.Current.MainWindow).Bounds.X;
                double deltaY = _ChildStartPosition.Bounds.Y - e.GetTouchPoint(Application.Current.MainWindow).Bounds.Y;
    
                Trace.WriteLine($"{deltaX} | {deltaY}");
    
                if (deltaX > deltaY && deltaX > 5)
                {
                    AssociatedObject.PanningMode = PanningMode.None;
                    AssociatedObject.Background = Brushes.Aqua;
                    ParentElement.PanningMode = PanningMode.HorizontalOnly;
                    _ChildMoving = true;
                }
                else if (deltaY > deltaX && deltaY > 5)
                {
                    AssociatedObject.PanningMode = PanningMode.VerticalOnly;
                    AssociatedObject.Background = Brushes.ForestGreen;
                    ParentElement.PanningMode = PanningMode.HorizontalOnly;
                    _ChildMoving = true;
                }
            }
    
            if (AssociatedObject.PanningMode == PanningMode.None)
            {
                e.Handled = true;
            }
        }
    
        private void _ChildUp(object sender, TouchEventArgs e)
        {
            AssociatedObject.Background = _DefaultBrush;
            _ChildTouchDown = false;
            _ChildMoving = false;
        }
    }
    

    Preview

    enter image description here