WPF Scrolling Content with Flicks and Gestures - C#
by Travis Feirtag, copyright 2009
I was looking for a way to scroll UI elements in WPF similar to how the popular
iPhone works or the HP MediaSmart software. I couldn't find what I was
looking for so it seemed like a good idea to work on a solution and write an
article. I'll preface this by saying that this is one possible solution.
I'm sure that there are some other ways to do the same thing in WPF but this is
the way I solved it.
For this article, I wanted to demonstrate how to use the ScrollViewer control in WPF
combined with gestures and flicks to create a simple image catalog. I'll use
the same code for accessing flicks and gestures from the C# libraries I already
wrote in a previous article. I hope you find it fun and useful.
ScrollViewer Control
WPF provides a ScrollViewer control that is a container for other controls and
creates a scrollable area. It is simple to create and add to a XAML file.
The developer can choose to display a horizontal and/or vertical scroll bar.
For this article, I chose to hide the scoll bar so that the user must scroll by
clicking on the content in the ScrollViewer.
WPF Scroll Content App
First I created a new WPF application and added a ScrollViewer control to the
XAML. I also want to populate the ScrollViewer with image thumbnails.
So I wrote a method called GetImageList that finds all image files in a given directory path and
loads them into a list. That list is then used to populate a StackPanel
control which
is a really useful control for ordering UI elements into a single row.
This is called in the constructor of the WPF window.
public WinMain()
{
InitializeComponent();
// Get images
List<Image> imageList = GetImageList(@"C:\Users\Public\Pictures\Sample Pictures");
// Create StackPanel and set child elements to horizontal orientation
StackPanel imgStackPnl = new StackPanel();
imgStackPnl.HorizontalAlignment = HorizontalAlignment.Left;
imgStackPnl.VerticalAlignment = VerticalAlignment.Top;
imgStackPnl.Orientation = Orientation.Horizontal;
// Iterate through each image and add to StackPanel
foreach (Image curImage in imageList)
{
imgStackPnl.Children.Add(curImage);
}
// Set content of ScrollViewer to StackPanel
this.myScrollViewer.Content = imgStackPnl;
}
private List<Image> GetImageList(string strPath)
{
List<Image> imageList = new List<Image>();
string strFilePath = "";
try
{
// supported image files
List<string> formatList = new List<string>();
formatList.Add(".jpg");
formatList.Add(".png");
formatList.Add(".bmp");
formatList.Add(".gif");
formatList.Add(".tif");
DirectoryInfo dirInfo = new DirectoryInfo(strPath);
FileInfo[] files = dirInfo.GetFiles();
foreach (FileInfo curFile in files)
{
// only look for image files
if (formatList.Contains(curFile.Extension.ToLower()) == false)
continue;
strFilePath = curFile.FullName;
Image curImage = new Image();
BitmapImage bmpImage = new BitmapImage();
bmpImage.BeginInit();
bmpImage.UriSource = new Uri(curFile.FullName, UriKind.Absolute);
bmpImage.EndInit();
curImage.Height = 140;
curImage.Stretch = Stretch.Fill;
curImage.Source = bmpImage;
curImage.Margin = new Thickness(10);
imageList.Add(curImage);
}
}
catch (Exception ex)
{
MessageBox.Show(string.Format("{0}-{1}", ex.Message, strFilePath));
}
return imageList;
}
The application should look like this so far. As you can see the scrollable
content is at the bottom of the window. I used the /Sample Pictures
directory found on Vista OS for example images. However there are no scroll bars
so if you click on the content, it will not scroll. We have to add some
event handlers first.
MouseDown, MouseUp and MouseMove Handlers
We'll start by hooking the MouseDown and MouseUp events of the Image elements.
We'll do it in the foreach loop when the Image elements are being added to the
StackPanel. We don't hook the MouseDown of the ScrollViewer because the
child elements will get the MouseDown event first.
// Iterate through each image and add to StackPanel
foreach (Image curImage in imageList)
{
curImage.MouseDown += new MouseButtonEventHandler(CurImage_MouseDown);
curImage.MouseUp += new MouseButtonEventHandler(CurImage_MouseUp);
imgStackPnl.Children.Add(curImage);
}
The event handlers will be used to set / reset the offset starting mouse position.
The variable _dbMousePosition will be used to hold the current postion of the
mouse.
private void CurImage_MouseDown(object sender, MouseButtonEventArgs mouseButtonEventArgs)
{
this._dbMousePosition= mouseButtonEventArgs.GetPosition(this.myScrollViewer).X;
}
private void CurImage_MouseUp(object sender, MouseButtonEventArgs mouseButtonEventArgs)
{
this._dbMousePosition= -1;
}
Now we hook the MouseMove event of the ScrollViewer control. This will be
used to move the StackPanel content in the ScrollViewer based on the initial position of
the mouse from the MouseDown event of the image.
private void MyScrollViewer_MouseMove(object sender, MouseEventArgs mouseEventArgs)
{
if (this._dbMousePosition > 0)
{
double dbX = mouseEventArgs.GetPosition(this.myScrollViewer).X;
double dbDelta = dbX - this._dbMousePosition;
double dbCurOffset = this.myScrollViewer.ContentHorizontalOffset;
double dbNew = dbCurOffset - dbDelta;
this.myScrollViewer.ScrollToHorizontalOffset(dbNew);
this._dbMousePosition = dbX;
}
}
At this point, you can run the program and click on any of the images in the ScrollViewer, drag them left and right to see the content scroll
the way we want it to. But we're not done yet, now we want to scroll the
content by using a tablet flick.
NOTE: I've provided a copy of the code that can be used without the tablet
flicks. You can scroll the content simply by clicking on the image and dragging.
This code is useful for anyone who doesn't have a tablet but still wants to
scroll content. The rest of the code in this article will use the tablet flicks
/ gestures and will require you to have a compatible tablet or touch panel.
WPF Scrolling Content without flicks, C# Source Code WpfScrollContent_no_flicks.zip
Animating the ScrollViewer Control
In WPF we have the class called DoubleAnimation which should be perfect for what we want to do.
Set the From value, To value and the Duration, and let it go. However what I found is that it unfortunately will not work. I wrote the
following code to animate the HorizontalOffset of the ScrollViewer control.
DoubleAnimation animScrollLeft = new DoubleAnimation();
animScrollLeft.From = dbCurOffset;
animScrollLeft.To = dbCurOffset - 200;
animScrollLeft.Duration = new Duration(TimeSpan.FromSeconds(3));
animScrollLeft.AccelerationRatio = 0.5;
// THIS DOESN'T WORK !!!
this.myScrollViewer.BeginAnimation(ScrollViewer.ContentHorizontalOffsetProperty, animScrollLeft);
Everything will compile fine and the application will seemingly run. But
when I try to execute this code, I get a runtime exception that states, "'ContentHorizontalOffset' property is not animatable on 'System.Windows.Controls.ScrollViewer' class because the IsAnimationProhibited flag has been set on the UIPropertyMetadata used to associate the property with the class."
What the..?!? I don't understand why but this dependency property cannot be set using
an Animation class. Next I attempted to override the metadata of the
dependency property by creating a custom class that inherits from ScrollViewer
to set the IsAnimationProhibited value to false.
In the static constructor, I tried running the following code.
static CustomScrollViewer()
{
UIPropertyMetadata uiPropMetadata = new UIPropertyMetadata();
uiPropMetadata.IsAnimationProhibited = false;
// THIS DOESN'T WORK !!!
ContentHorizontalOffsetProperty.OverrideMetadata(typeof(MyScrollViewer), uiPropMetadata);
}
This also throws a runtime exception stating that this is not allowed. So I needed a new approach to animating the content.
At this point I decided to create my own animation class.
Instead of animating using a dependency property, it uses a callback method to
tell the UI that a new value has been created. It is up to the main window
to update the ScrollViewer control's HorizontalOffset. This turned out to
work very well. It was important to be certain that the value was being
set in the UI thread instead of a separate thread so I made good use of the
Dispatcher class.
I created a class called ScrollAnimation. You can set From, To and
Duration values just like the DoubleAnimation class. The difference is
that you hook the UpdateAnimation event and call Start to begin the animation.
The class calculates all the needed values, creates a thread and runs the
animation for the specified duration. I won't show all the code here
because it would take up too much space. I'll leave it up to you to open
the ScrollAnimation.cs file and see how it works. In addition, I created a
custom EventArgs class called AnimationEventArgs. All it does is adds a
Value property to pass back to the subscriber of the event. I will show
how the WPF app calls the animation object and handles the callback.
To get the animation to start moving right, you simply get the current offset, set the values of the animation and tell it to Start.
private void ScrollContentRight()
{
double dbCurOffset = this.myScrollViewer.ContentHorizontalOffset;
this._animScroll.From = dbCurOffset;
this._animScroll.To = dbCurOffset + ANIM_SCROLL_AMOUNT;
this._animScroll.Duration = TimeSpan.FromSeconds(1);
this._animScroll.Start();
}
The animation object callback event is hooked in the Window constructor. So then each time a value is updated from the animation, the callback is sent from the other thread.
It is important to check the current Dispatcher to make certain you can set UI
elements from the correct thread.
{
// Hook the update animation callback event in the Window constructor
this._animScroll.UpdateAnimation += new UpdateAnimationHandler(AnimScroll_UpdateAnimation);
}
private void AnimScroll_UpdateAnimation(object objSender, AnimationEventArgs animationArgs)
{
if (this.Dispatcher == System.Windows.Threading.Dispatcher.CurrentDispatcher)
{
this.myScrollViewer.ScrollToHorizontalOffset(animationArgs.Value);
}
else
{
this.Dispatcher.BeginInvoke(new UpdateAnimationHandler(AnimScroll_UpdateAnimation),
new object[] { objSender, animationArgs });
}
}
Adding the Flicks
Now that we have a scroll animation, we need to add the flick references and hook the flick actions.
I used the FlickLib project that I created for the previous article. In WPF, we need to
access the message pump to find the flick and gesture messages to do something
with them. This is done using the WindowsInteropHelper class to hook a
MessageProc method.
private void WinMain_Loaded(object sender, RoutedEventArgs routedEventArgs)
{
WindowInteropHelper winHelper = new WindowInteropHelper(this);
try
{
HwndSource hwnd = HwndSource.FromHwnd(winHelper.Handle);
hwnd.AddHook(new HwndSourceHook(this.MessageProc));
}
catch (Exception ex)
{
MessageBox.Show(ex.Message);
}
}
The MessageProc simply looks at the message ID and finds the flick messages.
private IntPtr MessageProc(IntPtr hwnd, int nMsgID, IntPtr wParam, IntPtr lParam, ref bool bHandled)
{
if (nMsgID == Flicks.WM_TABLET_FLICK)
{
Flick flick = Flicks.ProcessMessage(lParam, wParam);
if (flick != null)
{
ProcessFlick(flick);
bHandled = true;
}
}
return IntPtr.Zero;
}
The ProcessFlicks method looks for the specific flick directions.
It was important to determine if the flick happened inside the ScrollViewer
control so that only flicks on the control make it move. So I used the
InputHitTest method from
the UIElement class. It will tell you if the given point is within the
control and which child control it was on. Once I determine that the flick
happened over the ScrollViewer control, then I scroll the content left or right.
I also added a section to handle the flick up direction. If the up flick
happens over an image, then it is put onto the main image control.
private void ProcessFlick(Flick flick)
{
Object objTest;
Point ptTest;
// Scroll content left if the left flick was on top of the scroll viewer
if (flick.Data.Direction == FLICKDIRECTION.FLICKDIRECTION_LEFT)
{
ptTest = this.myScrollViewer.PointFromScreen(new Point(flick.Point.X, flick.Point.Y));
objTest = this.myScrollViewer.InputHitTest(ptTest);
if (objTest != null)
ScrollContentRight();
}
// Scroll content left if the right flick was on top of the scroll viewer
if (flick.Data.Direction == FLICKDIRECTION.FLICKDIRECTION_RIGHT)
{
ptTest = this.myScrollViewer.PointFromScreen(new Point(flick.Point.X, flick.Point.Y));
objTest = this.myScrollViewer.InputHitTest(ptTest);
if (objTest != null)
ScrollContentLeft();
}
// If an up flick happened over an image, then put image on the main image control
if (flick.Data.Direction == FLICKDIRECTION.FLICKDIRECTION_UP)
{
ptTest = this.myScrollViewer.PointFromScreen(new Point(flick.Point.X, flick.Point.Y));
objTest = this.myScrollViewer.InputHitTest(ptTest);
if (objTest != null)
{
if (objTest is Image)
{
Image hitImage = (Image)objTest;
this.imgMain.Source = hitImage.Source;
}
}
}
} // end of ProcessFlick method
At this point, we've accomplished our goal of scrolling the content of a ScrollViewer class using flicks.
Adding Gestures
Now that we have the content scrolling using flicks, then let's add some gesture
support for the main image. The Zoom gesture will be used to enlarge or
shrink the image. The simplest way to accomplish this is to increase or
decrease the margin on the main image. As you can see from the code, I get
the current top margin and add ten. I realize that this is not the most
ideal zoom technique because it will cut off part of the image but it is really
meant to show proof of concept which I think it does quite nicely. I'll
leave it up to the reader to add their own zoom code to make a better image
zoom. I also left the case statement for the rotate gesture as a
placeholder. Hey, you don't want me to do all your work for you ?!
private void ProcessGesture(GestureData gData)
{
double dbTop = this.imgMain.Margin.Top;
switch (gData.GestureType)
{
case ENtrGestureType.Zoom:
GestureZoomData gzData = (GestureZoomData)gData;
if (gzData.ZoomStruct.mAmount < 0)
{
dbTop += 10;
}
else
{
dbTop -= 10;
}
this.imgMain.Margin = new Thickness(dbTop);
this.InvalidateVisual();
break;
case ENtrGestureType.Rotate:
GestureRotateData rData = (GestureRotateData)gData;
if (rData.RotateStruct.mAmount < 0)
{
}
else
{
}
break;
}
}
WPF Scrolling Content app with flicks and gestures, C# source code
WPF_Scrolling_Content.zip
Conclusion
I wanted to show how easy it was to get the ScrollViewer class to scroll content
without the use of the scrollbars. Improvements could be made to fix
zooming and add rotating. In addition, the flicks are coming from the OS.
Another improvement would be more advanced flicks that move content farther and
faster based on length and speed of flick. The current flicks detected by
the OS only give position and direction. I will have to work on
implementing the more advanced flicks. As always I hope the code I provided was useful and helpful.