Introduction
WPF has many rough edges which make it difficult for beginners to use--and which often require projects to include mountains of work-around code to do things that should be trivial. This is one such work-around, blessedly short, designed to fix the specific problem where you have a DataGrid
containing columns extending offscreen, and you don't want the grid's horizontal scroll position to change because the user clicked on the "wrong" cell in order to select the full row.
My Solution
The crux of the solution is to find and keep a reference to the ScrollViewer
which manages the DataGrid
's scroll position, and attach to it a handler which will be called when the scroll position changes. When that handler is called because a horizontal scroll happened immediately after a mouse click in a cell, the handler can suppress the undesirable behavior by resetting the ScrollViewer
's horizontal offset.
This solution is designed to allow normal scrolling with the horizontal scrollbar or keyboard. Horizontal scrolling is only suppressed when it happens very soon after a mouse button is clicked in one of the grid's cells.
I don't know that this is the very best solution to the problem, only that I'm not comfortable with solutions which begin with "Edit the control's template and...." Beyond that, my web researches turned up many possible solutions, but none of the ones I tried seemed to work very well.
Using the Code
The first part of this solution is in your application's XAML. The DataGrid
's definition must include a style that sets a handler for previewing mouse clicks:
<DataGrid ... >
<DataGrid.CellStyle>
<Style TargetType="DataGridCell">
<EventSetter Event="PreviewMouseDown" Handler="DataGridCell_PreviewMouseDown"/>
</Style>
</DataGrid.CellStyle>
</DataGrid>
We will discuss the handler's code a bit later.
The key to this solution is a structure we use to keep notes about when a mouse click occurred and what the ScrollViewer
's horizontal position was at that time. The structure can be a general-purpose one that stores state information with a timestamp. The one I chose to use is shown below:
struct StateTimestamp<T> {
#region Properties
public T SavedStateInfo { get; }
public DateTime Timestamp { get; }
#endregion Properties
#region Public Methods
public StateTimestamp( T savedStateInfo ) {
SavedStateInfo = savedStateInfo;
Timestamp = DateTime.Now;
}
public bool IsRecent( double withinMilliseconds ) =>
( ( DateTime.Now - Timestamp ).TotalMilliseconds <= withinMilliseconds );
#endregion Public Methods
}
In the source code that accompanies this article, the file named DependencyObject_Extensions.cs offers two methods you may find generally useful in your WPF programming adventures. These help you at runtime to find references to objects created by the presentation framework, objects to which you are unable to assign names in your XAML code, but you know they will exist while the application is running.
In this case, we know the DataGrid
will contain a ScrollViewer
at runtime, and we need a reference to it in order to be able to attach our ScrollChanged
event handler and to be able to read the value of HorizontalOffset
at runtime.
We cannot search for that reference inside the window's constructor. The ScrollViewer
will not exist until the grid actually appears onscreen. (If called in the window's constructor, MyGrid.FindVisualChild<ScrollViewer>()
would return null
.) Therefore, we must find another point in time after the grid has appeared to seek and save the reference to our ScrollViewer
. In this sample app, the grid is onscreen by the time the window's ContentRendered
event fires, so we can do what we need to do in our ContentRendered
event handler.
Here is the relevant portion of the ContentRendered
event handler. It refers to a member variable named m_gridScrollViewer
, which retains the reference to the grid's ScrollViewer
after we find it.
if ( MyGrid.FindVisualChild<ScrollViewer>() is ScrollViewer sv ) {
m_gridScrollViewer = sv;
m_gridScrollViewer.ScrollChanged += ScrollViewer_ScrollChanged;
}
Now we are ready to discuss the two handlers which make the solution work. These handlers refer to a constant, AUTO_SCROLL_PREVENTION_MILLISECONDS
which tells us for how long to suppress horizontal scrolls after a mouse is clicked in a grid cell; and a member variable, m_cellMouseClickInfo
, which is a StateTimestamp<double>
that stores the ScrollViewer
's horizontal offset at the time the mouse was clicked.
private void DataGridCell_PreviewMouseDown( object sender, MouseButtonEventArgs e ) {
m_cellMouseClickInfo = new StateTimestamp<double>
( savedStateInfo: m_gridScrollViewer.HorizontalOffset );
}
private void ScrollViewer_ScrollChanged( object sender, ScrollChangedEventArgs e ) {
if ( m_cellMouseClickInfo.IsRecent
( withinMilliseconds: AUTO_SCROLL_PREVENTION_MILLISECONDS ) ) {
m_gridScrollViewer.ScrollToHorizontalOffset( m_cellMouseClickInfo.SavedStateInfo );
}
}
That's it! In my testing, clicking in any partially visible cell of a row caused the row to be selected without any flickering or jumping around whatsoever. I hope the solution works as well for you.
Why So Much Code?
If you examine the properties of the ScrollChangedEventArgs
instance received by our ScrollChanged
handler, you will soon wonder why my solution ignores the HorizontalChange
property. Theoretically, one could merely note the time of every mouse click in a cell and make the ScrollChanged
handler undo auto-scrolling by the amount of the horizontal change. I did try that, but found I could not rely upon the value of HorizontalChange
to be the exact amount of adjustment needed. Horizontal scroll suppression often failed, and the grid would jump around a bit even when it seemed to be working.
The good news is, most of the sample code in the accompanying solution belongs to the DependencyObject_Extensions
class and StateTimestamp
structure, and these provide functionality you may well need elsewhere in your application. Less than 20 lines of code are specific to solving this problem in one grid in one window.
History
- 24th August, 2019: Initial version