Introduction
It is often desirable that during a drag operation if mouse moves to an edge, the underlying view scrolls to bring 'more' into view. This article introduces you to a way to achieve such a scrolling automatically during a drag operation inside a WPF control.
The presented solution can be easily enabled on any WPF control(for e.g. Grid
, StackPanel
, WrapPanel
or even an ItemsControl
) be it placed inside a ScrollViewer
or contains a ScrollViewer inside its template (for e.g. TreeView
, ListBox
etc.). What the solution is not about though is how to enable and handle drag and drop operations.
WPF is known to be highly flexible. For most cases this is WPF's biggest strength but when you try to write a generic piece of utility code you soon discover that this very strength makes it quite hard to cover all the possible cases. This article does not claim to work in absolutely all possible scenarios but I have nevertheless tried to attain this goal while trying to keep the solution as generic as possible. I am confident that it will work seamlessly for vast majority of cases.
Other Features
Reusable
Attached properties are one of the most powerful feature of WPF.
Apart from associating additional state with a DependencyObject
, they can also be used to attach new behavior while remaining XAML friendly.
I use this approach in the solution to keep it reusable and distributable in a library.
The attached demo application contains a class named as AutoDragScrollingProvider
and presents an attached property EnableAutomaticScrolling
to easily enable automatic scrolling on any UIElement
it gets attached to.
Smooth Scrolling
You may be aware that WPF supports two modes of scrolling:
- Pixel based scrolling
- and 'logical' scrolling where scrolling is performed logically which in most cases mean item by item scrolling. For e.g.
ListBox
uses item by item scrolling by default
Pixel based scrolling is quite smooth and is in line with what those of us coming from WinForms world expect. Logical scrolling on the other hand appears to be a bit jerky to the end user especially in case of item by item scrolling as an entire item moves out and new one comes in.
To enable logical scrolling, ScrollViewer.
CanContentScroll
property must be set to true and one of the following conditions must be met:
- Either
ScrollViewer.Content
implements IScrollInfo
interface
- or
ScrollViewer
is part of a template and it contains ItemsPresenter
in its child visual tree and ItemsPresenter's
child (i.e. panel contained in ItemsControl.ItemsPanel
template) implements IScrollInfo
interface
For instance,
VirtualizingStackPanel
and
StackPanel
implements
IScrollInfo
and if above scenario is met,
ScrollViewer
will scroll logically as dictated by these panels.
While dragging it is often desirable to control the speed of scrolling by 'pressure' of the mouse i.e nearer the mouse to the edge (giving impression of a 'hard press') faster should be the scrolling. For the purpose of this article (and attached demo) I use words 'smooth scrolling' in this context rather than pixel based scrolling as described above. In this context 'smooth scrolling' does not affect the ability to perform item by item scrolling but just that scrolling pace is more controllable by the user and hence provides more pleasant user experience.
AutoDragScrollingProvider
makes sure that pixel based scrolling is always smooth (in just described context) while logical scrolling may or may not be smooth depending on specific implementation of IScrollInfo
. To support smooth scrolling, IScrollInfo implementation must support fractional changes in scroll offsets.
For instance StackPanel
supports smooth scrolling in the direction opposite to its orientation while in the direction of its orientation it does not support it.
Case of VirtualizingStackPanel
is a bit more complex than that. When it is not hosted inside an ItemsControl
(as dictated by ItemsControl.ItemsPanel) it behaves like a StackPanel
but otherwise it supports smooth scrolling irrespective of its orientation.
It must be remembered however, that if CanContentScroll
property is false or none of the necessary condition is met, ScrollViewer
uses ScrollContentPresenter
(which itself implements IScrollInfo
) to provide pixel based scrolling by default.
Another point to remember is that any IScrollInfo
implementation can be used to perform non-smooth scrolling by using its line scrolling methods.
AutoDragScrollingProvider
attempts to make use of 'smooth scrolling' wherever possible as it makes for a more pleasant user experience. It automatically takes care of different cases arising due to StackPanel
and VirtualizingStackPanel
as far as support for smooth scrolling is concerned and presents a set of attached properties to enforce 'non-smooth' line scrolling for those IScrollInfo
custom implementations which don't support smooth scrolling.
Scrolling irrespective of origin of drag operation
In certain drag and drop operations it is often required that drag starts from a particular control and terminates in another. For e.g. Moving an item from one list to another by dragging it from former and dropping it on later. In other cases drag and drop is restricted to a single control (for e.g. re-ordering of rows by dragging).
AutoDragScrollingProvider
automatically works on any control that has its AllowDrop
property set to true (this is necessary to enable the dragging itself) and off-course on which EnableAutomaticScrolling
is set to true. It is not dependent on whether drag started from underlying control or not. Hence, it is able to support both the above scenarios automatically.
Using the code
A demo application is attached with this article. It exhibits how
AutoDragScrollingProvider
can be used in different scenarios.
To enable automatic scrolling simply set AutoDragScrollingProvider.EnableAutomaticScrolling
attached property to true and remember to set AllowDrop
to true as well. For e.g.
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<WrapPanel AllowDrop="True" local:AutoDragScrollingProvider.EnableAutomaticScrolling="True">
...child elements...
<WrapPanel>
<ScrollViewer>
If you happen to use a custom implementation of IScrollInfo
and you know it does not support smooth scrolling (i.e. fractional scroll offset changes) in a particular direction you can set AutoDragScrollingProvider.UseLineScrollingForHorizontalChange
or AutoDragScrollingProvider.UseLineScrollingForVerticalChange
attached properties to true to enforce use of 'non-smooth' line scrolling in respective directions.
Point of Interest
Handling of IScrollInfo
in a generic fashion can be a bit tricky. IScrollInfo
is an interface and hence a lot depends on its specific implementation. Some implementations of it present their scroll properties (offsets, view port dimensions etc.) in pixels, some in logical units, and still some in logical units for one scroll direction while pixels for another.
AutoDragScrollingProvider
is able to deal with this in a consistent manner by calculating average width (or height) per item in pixels for logical scrolling cases (for pixel based scrolling average always comes out to be 1 and as such does not affect subsequent calculation) and then using the pixel units to dictate the amount of scrolling required.
How it works
- When the attached property is set to true,
AutoDragScrollingProvider
subscribes PreviewDragEnter
and PreviewDragOver
events on the UIElement
it gets attached to
- In the event handler for these events, it searches for the relevant
IScrollInfo
in force (which by default is ScrollContentPresenter
) and caches it for subsequent uses
- Next it gets the position of the mouse with in the control represented by cached
IScrollInfo
UIElement scrollable = _scrollInfo as UIElement;
var mousePos = e.GetPosition(scrollable);
- Now it checks if the mouse is with in 20 pixels from any edge and if it is it calculates average width or average height (as the case may be). If the view-port dimension is in pixels this average always comes out to be 1, otherwise this value can be treated as average per item in pixels.
var avgWidth = scrollable.RenderSize.Width / _scrollInfo.ViewportWidth;
- Next it calculates the amount by which scroll offset needs to change and then changes the offset to cause scrolling. Nearer the mouse to the edge, greater is the delta. As you may guess delta in most cases is a fractional value.
var delta = (mousePos.X - 20) / avgWidth; _scrollInfo.SetHorizontalOffset(_scrollInfo.HorizontalOffset + delta);
- For cases where line scrolling is enforced using the other two attached properties, well it simply causes line scrolling instead of delta based scrolling (Note: Please see the Updates section). For e.g.
_scrollInfo.LineLeft();
Updates
Update 1
Replacing UseLineScrollingForHorizontalChange
and UseLineScrollingForVerticalChange
attached properties with LineSizeForHorizontalChange
and LineSizeForVerticalChange
attached properties. Demo Application V2 contains the update for AutoDragScrollingProvider class. Both of these properties are of type double with default value of double.NaN
.
When any of these properties is provided with a valid value, scrolling in the respective direction uses line scrolling as earlier but now, just like the default 'smooth scrolling' case, it is dependent on the calculated delta and thus allow user to control the scrolling speed using 'mouse pressure'. Nearer the mouse to the edge, faster is the scrolling.
For StackPanel
and VirtualizingStackPanel
these properties are automatically taken care of. For those custom implementations of IScrollInfo
which do not behave well with default smooth scrolling, these properties can be set to instruct line scrolling while at the same time giving the impression of smooth scrolling. As explained earlier ability to control speed of the scroll makes for a more pleasant user experience. If you try Demo Application V1 you can see that in case of line scrolling, scrolling happens just too fast to be useful. Contrast the same case with Demo Application V2 where scrolling speed can be controlled.
How is this achieved
Please recall from previous discussion that the calculated delta is almost always fractional. Some implementations of IScrollInfo
do not support 'smooth scrolling' because they do not support fractional scroll offset changes. So the solution is to accumulate the calculated delta till it has exceeded the 'amount' of scroll affected by the line scroll (or in other words 'line size') and only then cause the line scroll. Thus we can simulate smooth scrolling even when line scrolling is required. It must be remembered though that the line size itself must be in units understood by the IScrollInfo
implementation. For instance for StackPanel
it is 1.0, the minimal offset change it is able to support.
var delta = (mousePos.X - 20) / avgWidth; var lineSize = GetLineSizeForHorizontalChange(scrollable);
if (!double.IsNaN(lineSize))
{
_totalHorizontalDelta += delta;
if ((0 - _totalHorizontalDelta) >= lineSize) {
_scrollInfo.LineLeft();
_totalHorizontalDelta = 0d;
}
}
else
{
_scrollInfo.SetHorizontalOffset(_scrollInfo.HorizontalOffset + delta);
}