9

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?

josh2112
  • 829
  • 6
  • 22
  • Could you test my solution approach? I'm also interested how to solve this issue! – Dominic Jonas Jul 19 '18 at 07:10
  • Thanks for your answer Jonas! My touch device seems to be on the fritz today. I'll excited for a working solution and I'll test it as soon as I can. – josh2112 Jul 20 '18 at 17:33

3 Answers3

2

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

Community
  • 1
  • 1
Dominic Jonas
  • 4,717
  • 1
  • 34
  • 77
  • Thanks for the answer @Dominic, but I got nowhere with this. AssociatedObject.RaiseEvent() just results in a StackOverflow since the behavior is continually redirecting the event back to the same control. Making the change to send the event to the parent got rid of the exception, but made the whole window unresponsive to touch events. Besides -- bubbling the event from the ItemsControl to the parent ScrollViewer -- doesn't that happen anyway? – josh2112 Jul 20 '18 at 19:51
  • I also thought what you might be trying to do was bubble touch events from the child inner ScrollViewer to the outer ScrollViewer, in which case the behavior should be one level up from the ItemsControl. But that doesn't work since the inner ScrollViewer/ItemsControl is a DataTemplate so it doesn't have a visual parent. – josh2112 Jul 20 '18 at 19:52
  • Meh... I don't like it... it feels hacky and it's difficult to get the horizontal scrollviewer to activate... but it does answer the question so I'm awarding you the bounty. – josh2112 Jul 24 '18 at 17:32
  • Thank you, but I did not expect you to accept them. I'm still interested in a better solution and will post it as soon as I found one! – Dominic Jonas Jul 25 '18 at 06:41
  • @DominicJonas did you find a better way? – ogomrub May 29 '19 at 09:20
  • @ogomrub Unfortunately, no – Dominic Jonas May 29 '19 at 09:21
  • Hi @DominicJonas I manage to solve it by using telerik touch manager. Maybe it inspires you. My inner scrollviewers only scroll horizontal and panning mode is set to "HorizontalOnly", then from telerik I attach telerik:TouchManager.ScrollViewerSwipeMode="Parent" and I handle only "horizontal" swipes and let the "vertical" ones flow to parent without changing panning modes in runtime. Still not as good/smooth as I would wish but good enough. – ogomrub May 29 '19 at 13:20
0

Here is a different working solution which uses similar attached Behaviors to make nested ScrollViewers work properly.

0

As an amateur. I could not get a better answer on this same question.

And after doing some research , Here is my way of doing it.

It simply uses a Bubbled Routing Event: TouchMove. Both the scrollviewer will scroll as given by panningmode. But as user scrolls the inner scrollviewer, outer scrollviewer waits for the TouchMove Event and when the event reaches the outer scrollviewer, it checks the source and if the event is generated from inner scrollviewer, it will process and scrolls the outer scrollviewer.