Expanders in Grid - wpf

This is going to be straight forward no doubt, but for what ever reason, my mind is drawing a blank on it.
I've got a small, non-resizeable window (325x450) which has 3 Expanders in it, stacked vertically. Each Expander contains an ItemsControl that can potentially have a lot of items in and therefore need to scroll.
What I can't seem to get right is how to layout the Expanders so that they expand to fill any space that is available without pushing other elements off the screen. I can sort of achieve what I'm after by using a Grid and putting each expander in a row with a * height, but this means they are always taking up 1/3 of the window each which defeats the point of the Expander :)
Crappy diagram of what I'm trying to achieve:

This requirement is a little unusal because the you want the state of the Children in the Grid to decide the Height of the RowDefinition they are in.
I really like the layout idea though and I can't believe I never had a similar requirement myself.. :)
For a reusable solution I would use an Attached Behavior for the Grid.
The behavior will subscribe to the Attached Events Expander.Expanded and Expander.Collapsed and in the event handlers, get the right RowDefinition from Grid.GetRow and update the Height accordingly. It works like this
<Grid ex:GridExpanderSizeBehavior.SizeRowsToExpanderState="True">
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<Expander Grid.Row="0" ... />
<Expander Grid.Row="1" ... />
<Expander Grid.Row="2" ... />
<!-- ... -->
</Grid>
And here is GridExpanderSizeBehavior
public class GridExpanderSizeBehavior
{
public static DependencyProperty SizeRowsToExpanderStateProperty =
DependencyProperty.RegisterAttached("SizeRowsToExpanderState",
typeof(bool),
typeof(GridExpanderSizeBehavior),
new FrameworkPropertyMetadata(false, SizeRowsToExpanderStateChanged));
public static void SetSizeRowsToExpanderState(Grid grid, bool value)
{
grid.SetValue(SizeRowsToExpanderStateProperty, value);
}
private static void SizeRowsToExpanderStateChanged(object target, DependencyPropertyChangedEventArgs e)
{
Grid grid = target as Grid;
if (grid != null)
{
if ((bool)e.NewValue == true)
{
grid.AddHandler(Expander.ExpandedEvent, new RoutedEventHandler(Expander_Expanded));
grid.AddHandler(Expander.CollapsedEvent, new RoutedEventHandler(Expander_Collapsed));
}
else if ((bool)e.OldValue == true)
{
grid.RemoveHandler(Expander.ExpandedEvent, new RoutedEventHandler(Expander_Expanded));
grid.RemoveHandler(Expander.CollapsedEvent, new RoutedEventHandler(Expander_Collapsed));
}
}
}
private static void Expander_Expanded(object sender, RoutedEventArgs e)
{
Grid grid = sender as Grid;
Expander expander = e.OriginalSource as Expander;
int row = Grid.GetRow(expander);
if (row <= grid.RowDefinitions.Count)
{
grid.RowDefinitions[row].Height = new GridLength(1.0, GridUnitType.Star);
}
}
private static void Expander_Collapsed(object sender, RoutedEventArgs e)
{
Grid grid = sender as Grid;
Expander expander = e.OriginalSource as Expander;
int row = Grid.GetRow(expander);
if (row <= grid.RowDefinitions.Count)
{
grid.RowDefinitions[row].Height = new GridLength(1.0, GridUnitType.Auto);
}
}
}

If you don't mind a little code-behind, you could probably hook into the Expanded/Collapsed events, find the parent Grid, get the RowDefinition for the expander, and set the value equal to * if its expanded, or Auto if not.
For example,
Expander ex = sender as Expander;
Grid parent = FindAncestor<Grid>(ex);
int rowIndex = Grid.GetRow(ex);
if (parent.RowDefinitions.Count > rowIndex && rowIndex >= 0)
parent.RowDefinitions[rowIndex].Height =
(ex.IsExpanded ? new GridLength(1, GridUnitType.Star) : GridLength.Auto);
And the FindAncestor method is defined as this:
public static T FindAncestor<T>(DependencyObject current)
where T : DependencyObject
{
// Need this call to avoid returning current object if it is the
// same type as parent we are looking for
current = VisualTreeHelper.GetParent(current);
while (current != null)
{
if (current is T)
{
return (T)current;
}
current = VisualTreeHelper.GetParent(current);
};
return null;
}

Related

Clone element to display as an image/bitmap

I'm trying to create a snapshot/image/bitmap of an element that I can display as content in another control.
It seems the suggested way to do this is with a VisualBrush, but I can't seem to get it to create a snapshot of the current value and keep that state. When you alter the original source, the changes are applied to all the "copies" that have been made too.
I have made a simple example to show what I mean.
What I want is for the items added to the stackpanel to have the opacity that was set when they were cloned. But instead, changing the opacity on the source changes all "clones".
<StackPanel Width="200" x:Name="sp">
<DockPanel>
<Button Content="Clone"
Click="OnCloneButtonClick" />
<TextBlock Text="Value" x:Name="tb" Background="Red" />
</DockPanel>
</StackPanel>
private void OnCloneButtonClick(object sender, RoutedEventArgs e)
{
tb.Opacity -= 0.1;
var brush = new VisualBrush(tb).CloneCurrentValue();
sp.Children.Add(new Border() { Background = brush, Width = tb.ActualWidth, Height = tb.ActualHeight });
}
I am afraid the visual elements aren't cloned when you call CloneCurrentValue().
You will have to clone the element yourself, for example by serializing the element to XAML and then deserialize it back using the XamlWriter.Save and XamlReader.Parse methods respectively:
private void OnCloneButtonClick(object sender, RoutedEventArgs e)
{
tb.Opacity -= 0.1;
var brush = new VisualBrush(Clone(tb));
sp.Children.Add(new Border() { Background = brush, Width = tb.ActualWidth, Height = tb.ActualHeight });
}
private static Visual Clone(Visual visual)
{
string xaml = XamlWriter.Save(visual);
return (Visual)XamlReader.Parse(xaml);
}

Getting DataGridCell from nested controls in the template column without iterating through the visual tree

The DataGridCell does not appear to be in the VisualTree of controls.
I have a DataGridTemplateColumn that contains a Rectangle and Label in a Stack Panel inside a Grid.
<t:DataGridTemplateColumn>
<t:DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid FocusManager.FocusedElement="{Binding ElementName=swatch}">
<StackPanel Orientation="Horizontal">
<Rectangle Name="swatch" PreviewMouseLeftButtonDown="swatch_PreviewMouseLeftButtonDown" />
<Label/>
</StackPanel>
</Grid>
</DataTemplate>
</t:DataGridTemplateColumn.CellTemplate>
</t:DataGridTemplateColumn>
I wanted the PreviewMouseLeftButtonDown event to iterate upwards through the visual tree until it finds the DataGridCell but after the Grid the parent element is null.
private void swatch_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
DataGridCell cell = null;
while (cell == null)
{
cell = sender as DataGridCell;
sender = ((FrameworkElement)sender).Parent;
}
MethodForCell(sender);
}
Reading the link below it seems that in the DataGrid's Visual tree UIControls are set as the content property of the DataGridCell. So how can I get the DataGridCell from the Rectangle?
http://blogs.msdn.com/b/vinsibal/archive/2008/08/14/wpf-datagrid-dissecting-the-visual-layout.aspx
Change your eventhandler on this one:
private void swatch_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
DataGridCell cell = null;
while (cell == null)
{
cell = sender as DataGridCell;
if (((FrameworkElement)sender).Parent != null)
sender = ((FrameworkElement)sender).Parent;
else
sender = ((FrameworkElement)sender).TemplatedParent;
}
}
I found this to be much better
public static T GetVisualParent<T>(Visual child) where T : Visual
{
T parent = default(T);
Visual v = (Visual)VisualTreeHelper.GetParent(child);
if (v == null)
return null;
parent = v as T;
if (parent == null)
{
parent = GetVisualParent<T>(v);
}
return parent;
}
And you would call it like this:
var cell = GetVisualParent<DataGridCell>(e.Source);

How to move Grid programmatically

I have a Grid in my application and I want to move it around using four buttons (up, down, left, right). I have the grid's position (X and Y), but I don't know how to set a new position.
On the keyboard down event, you can change the Margin property of the grid. This will work best if you have the grid nested within another Grid or Canvas. You have the keep the parent container in mind and how it will work with the layout.
Assuming it is nested in another Grid control, here is a sample of what the code might look like:
private void OnKeyDown( object sender, System.Windows.Input.KeyEventArgs e )
{
if( e.Key == System.Windows.Input.Key.Up )
{
Thickness orig = MyGrid.Margin;
MyGrid.Margin = new Thickness( margin.Left, margin.Up - 5, margin.Right, margin.Bottom );
}
else if( ... )
...
}
NOTE: You probably don't even need to allocate a new Thickness object. Just change the one that is there. This is purely for example's sake.
The way to move the grid depends on the container that it is contained in. If it is placed inside a Canvas then you can just bind the Canvas.Left and Canvas.Top properties of the grid to some properties in your viewmodel and you can then change those numbers on up and down buttons like so:
<Canvas Width="400" Height="400">
<Grid Height="20" Width="20" Canvas.Left="{Binding GridLeft}" Canvas.Top="{Binding GridTop}" Background="Red" />
</Canvas>
And ViewModel will be like this
class ViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged = delegate { };
int gridLeft;
public int GridLeft
{
get { return gridLeft; }
set
{
gridLeft = value;
PropertyChanged(this, new PropertyChangedEventArgs("GridLeft"));
}
}
int gridTop;
public int GridTop
{
get { return gridTop; }
set
{
gridTop = value;
PropertyChanged(this, new PropertyChangedEventArgs("GridTop"));
}
}
}

Prevent Automatic Horizontal Scroll in TreeView

Whenever a node is selected in my treeview, it automatically does a horizontal scroll to that item. Is there a way to disable this?
Handle the RequestBringIntoView event and set Handled to true, and the framework won't try to bring the item into view. For example, do something like this in your XAML:
<TreeView>
<TreeView.ItemContainerStyle>
<Style TargetType="TreeViewItem">
<EventSetter Event="RequestBringIntoView" Handler="TreeViewItem_RequestBringIntoView"/>
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
And then this in your code-behind:
private void TreeViewItem_RequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
e.Handled = true;
}
I managed to solve the problem using the following:
<TreeView ScrollViewer.HorizontalScrollBarVisibility="Hidden">
<TreeView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel MaxWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ContentPresenter, AncestorLevel=1}}" />
</ItemsPanelTemplate>
</TreeView.ItemsPanel>
</TreeView>
I bind the width of the StackPanel which renders the ItemsPanel here, to the ActualWidth of ContentPresenter in the TreeView.
It also works nice with the "hacked" Stretching TreeView by: http://blogs.msdn.com/b/jpricket/archive/2008/08/05/wpf-a-stretching-treeview.aspx (I modified that solution not to remove grid column, but to change Grid.Column property of the first Decorator element from 1 to 2).
To offer a slightly simplified version of #lena's answer:
To scroll vertically while preserving the horizontal scroll position, and with no unwanted side effects, in the XAML, add event handlers for RequestBringIntoView and Selected:
<TreeView>
<TreeView.ItemContainerStyle>
<Style TargetType="{x:Type TreeViewItem}">
<EventSetter Event="RequestBringIntoView" Handler="TreeViewItem_RequestBringIntoView"/>
<EventSetter Event="Selected" Handler="OnSelected"/>
...
In the code behind, add two event handlers:
private void TreeViewItem_RequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
// Ignore re-entrant calls
if (mSuppressRequestBringIntoView)
return;
// Cancel the current scroll attempt
e.Handled = true;
// Call BringIntoView using a rectangle that extends into "negative space" to the left of our
// actual control. This allows the vertical scrolling behaviour to operate without adversely
// affecting the current horizontal scroll position.
mSuppressRequestBringIntoView = true;
TreeViewItem tvi = sender as TreeViewItem;
if (tvi != null)
{
Rect newTargetRect = new Rect(-1000, 0, tvi.ActualWidth + 1000, tvi.ActualHeight);
tvi.BringIntoView(newTargetRect);
}
mSuppressRequestBringIntoView = false;
}
private bool mSuppressRequestBringIntoView;
// Correctly handle programmatically selected items
private void OnSelected(object sender, RoutedEventArgs e)
{
((TreeViewItem)sender).BringIntoView();
e.Handled = true;
}
Matthew, I manged to preserve vertical scrolling, and only prevent horizontal scrolling by restoring the horizontal position after a scroll caused by the RequestBringIntoView event .
private double treeViewHorizScrollPos = 0.0;
private bool treeViewResetHorizScroll = false;
private ScrollViewer treeViewScrollViewer = null;
private void TreeViewItemRequestBringIntoView( object sender, RequestBringIntoViewEventArgs e )
{
if ( this.treeViewScrollViewer == null )
{
this.treeViewScrollViewer = this.DetailsTree.Template.FindName( "_tv_scrollviewer_", this.DetailsTree ) as ScrollViewer;
if( this.treeViewScrollViewer != null )
this.treeViewScrollViewer.ScrollChanged += new ScrollChangedEventHandler( this.TreeViewScrollViewerScrollChanged );
}
this.treeViewResetHorizScroll = true;
this.treeViewHorizScrollPos = this.treeViewScrollViewer.HorizontalOffset;
}
private void TreeViewScrollViewerScrollChanged( object sender, ScrollChangedEventArgs e )
{
if ( this.treeViewResetHorizScroll )
this.treeViewScrollViewer.ScrollToHorizontalOffset( this.treeViewHorizScrollPos );
this.treeViewResetHorizScroll = false;
}
I had a similar problem. I needed to prevent horizontal scroll but preserve vertical scroll. My solution is to handle OnRequestBringIntoView method as I want it to behave. I created a ResourceDictionary for a TreeViewItem and added EventSetters for OnSelected and OnRequestBringIntoView methods.
MyResourceDictionary.xaml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" x:Class="Resources.MyResourceDictionary" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style TargetType="TreeViewItem" x:Key="treeitem" >
<EventSetter Event="RequestBringIntoView" Handler="OnRequestBringIntoView"/>
<EventSetter Event="Selected" Handler="OnSelected"/>
</Style>
</ResourceDictionary>
MyResourceDictionary.xaml.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
namespace Resources
{
partial class MyResourceDictionary:ResourceDictionary
{
public MyResourceDictionary()
{
InitializeComponent();
}
private void OnRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
e.Handled = true; //prevent event bubbling
var item = (TreeViewItem)sender;
TreeView tree = GetParentTree(item) as TreeView;
if(tree!=null)
{
var scrollViewer = tree.Template.FindName("_tv_scrollviewer_", tree) as ScrollViewer;
if (scrollViewer != null)
{
scrollViewer.ScrollToLeftEnd();//prevent horizontal scroll
Point relativePoint = item.TransformToAncestor(tree).Transform(new Point(0, 0));//get position of a selected item
if (relativePoint.Y <= scrollViewer.ContentVerticalOffset) return;//do no scroll if we select inside one 'scroll screen'
scrollViewer.ScrollToVerticalOffset(relativePoint.Y);//scroll to Y of a selected item
}
}
}
private DependencyObject GetParentTree(DependencyObject item)
{
var target = VisualTreeHelper.GetParent(item);
return target as TreeView != null ? target : GetParentTree(target);
}
private void OnSelected(object sender, RoutedEventArgs e) //handle programmatically selected items
{
var item = (TreeViewItem)sender;
item.BringIntoView();
e.Handled = true;
}
}
}
Following solution is more simple and fully tested and more compatible, You don't need to calculate and change scrollbar offset, what you need is moving horizontal scrollbar to left, since "RequestBringIntoView" event routing strategy is bubbling, you simply need to do it on last item reached event.
Name scrollViewer control "_tv_scrollviewer_"
<TreeView>
<TreeView.ItemContainerStyle>
<Style TargetType="{x:Type TreeViewItem}">
<EventSetter Event="RequestBringIntoView" Handler="TreeViewItem_RequestBringIntoView"/>
On code behind:
private void OnRequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
var item = (TreeViewItem)sender;
if (item != null)
{
// move horizontal scrollbar only when event reached last parent item
if (item.Parent == null)
{
var scrollViewer = itemsTree.Template.FindName("_tv_scrollviewer_", itemsTree) as ScrollViewer;
if (scrollViewer != null)
Dispatcher.BeginInvoke(DispatcherPriority.Loaded, (Action)(() => scrollViewer.ScrollToLeftEnd()));
}
}
}
#lena's solution of preserving vertical scrolling worked best for me. I've iterated on it a little bit:
private void TreeViewItem_RequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
var treeViewItem = (TreeViewItem)sender;
var scrollViewer = treeView.Template.FindName("_tv_scrollviewer_", treeView) as ScrollViewer;
Point topLeftInTreeViewCoordinates = treeViewItem.TransformToAncestor(treeView).Transform(new Point(0, 0));
var treeViewItemTop = topLeftInTreeViewCoordinates.Y;
if (treeViewItemTop < 0
|| treeViewItemTop + treeViewItem.ActualHeight > scrollViewer.ViewportHeight
|| treeViewItem.ActualHeight > scrollViewer.ViewportHeight)
{
// if the item is not visible or too "tall", don't do anything; let them scroll it into view
return;
}
// if the item is already fully within the viewport vertically, disallow horizontal scrolling
e.Handled = true;
}
What this does is let the ScrollViewer scroll normally if the item isn't already in the viewport vertically. However for the actual "annoying" case (where the item is already visible), it sets e.Handled to true, thus preventing horizontal scrolling.
I had a DataGrid that I wanted to do the same operation on and used POHB's answer mostly. I had to modify it for my solution. The code is shown below. The datagrid is a 2 x 2 datagrid with the first column being thin and the second being very wide (1000+). The first column is frozen. I hope this helps someone out. -Matt
public partial class MyUserControl : UserControl
{
private ScrollContentPresenter _scrollContentPresenter;
private ScrollViewer _scrollViewer;
private double _dataGridHorizScrollPos = 0.0;
private bool _dataGridResetHorizScroll = false;
public MyUserControl()
{
// setup code...
_dataGrid.ApplyTemplate();
_scrollViewer = FindVisualChild<ScrollViewer>(_dataGrid);
_scrollViewer.ScrollChanged += new ScrollChangedEventHandler(DataGridScrollViewerScrollChanged);
_scrollContentPresenter = FindVisualChild<ScrollContentPresenter>(_scrollViewer);
_scrollContentPresenter.RequestBringIntoView += new RequestBringIntoViewEventHandler(_scrollContentPresenter_RequestBringInputView);
}
private void DataGridScrollViewerScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (_dataGridResetHorizScroll)
{
_scrollViewer.ScrollToHorizontalOffset(_dataGridHorizScrollPos);
}
// Note: When the row just before a page change is selected and then the next row on the
// next page is selected, a second event fires setting the horizontal offset to 0
// I'm ignoring those large changes by only recording the offset when it's large. -MRB
else if (Math.Abs(e.HorizontalChange) < 100)
{
_dataGridHorizScrollPos = _scrollViewer.HorizontalOffset;
}
_dataGridResetHorizScroll = false;
}
public T FindVisualChild<T>(DependencyObject depObj) where T : DependencyObject
{
if (depObj != null)
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(depObj); i++)
{
DependencyObject child = VisualTreeHelper.GetChild(depObj, i);
if ((child != null) && (child is ScrollViewer))
{
// I needed this since the template wasn't applied yet when
// calling from the constructor
(child as ScrollViewer).ApplyTemplate();
}
if (child != null && child is T)
{
return (T)child;
}
T childItem = FindVisualChild<T>(child);
if (childItem != null) return childItem;
}
}
return null;
}
private void _scrollContentPresenter_RequestBringInputView(object sender, RequestBringIntoViewEventArgs e)
{
_dataGridResetHorizScroll = true;
}

Scroll ListViewItem to be at the top of a ListView

In WPF, I know I can use ListView.ScrollIntoView to scroll a particular item into view, but it will always do the least amount of scrolling so that the item is shown.
How can I make it scroll so that the item I want to show is scrolled to the top of the ListView?
I've thought about calling ScrollIntoView twice, once for the item I want at the top, and once for the last shown item, but I don't know how to find out what the last shown item.
We can do this by obtaining the ScrollViewer that is present in the ListView's ControlTemplate. If you have access to a ScrollViewer then there are a lot of different scrolling methods exposed.
First, we can create a ListView that we want to add this effect to:
<ListView ItemsSource="{Binding Percents}"
SelectionChanged="OnSelectionChanged"
x:Name="uiListView"/>
public List<int> Percents { get; set; }
public Window1()
{
InitializeComponent();
Percents = new List<int>();
for (int i = 1; i <= 100; i++)
{
Percents.Add(i);
}
this.DataContext = this;
}
We will also need something that we can use to obtain the ScrollViewer from the ListView. I've used something similar to this before to work with custom scrolling, and we can use it here as well.
public static DependencyObject GetScrollViewer(DependencyObject o)
{
if (o is ScrollViewer)
{ return o; }
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(o); i++)
{
var child = VisualTreeHelper.GetChild(o, i);
var result = GetScrollViewer(child);
if (result == null)
{
continue;
}
else
{
return result;
}
}
return null;
}
Now, we just need to handle the SelectionChanged event. Because we are trying to scroll an item to the top of the list, the best option is to scroll to the bottom, and then re-scroll up to our selected item. As you said, ScrollIntoView will scroll just until the item is visible, and so once the selected item reaches the top as it scrolls back up, it will cease leaving us with our selected item at the very top of the list.
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
ScrollViewer scrollViewer = GetScrollViewer(uiListView) as ScrollViewer;
scrollViewer.ScrollToBottom();
uiListView.ScrollIntoView(e.AddedItems[0]);
}
This is an alternative to the answer of #rmoore that avoids scrolling to the bottom. Also note that this is only usefull in cases where SelectionMode=Single.
In case that ScrollViewer.CanContentScroll=True the ScrollViewer can scroll directly to the SelectedIndex.
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
ScrollViewer scrollViewer = GetScrollViewer(uiListView) as ScrollViewer;
scrollViewer.ScrollToVerticalOffset(listView.SelectedIndex);
}
and in case ScrollViewer.CanContentScroll=False some additional XAML is required:
<ListView ScrollViewer.CanContentScroll="False" ItemsSource="{Binding Percents}" x:Name="uiListView">
<ListView.ItemContainerStyle>
<Style TargetType="{x:Type ListViewItem}">
<EventSetter Event="Selected" Handler="OnSelected"/>
</Style>
</ListView.ItemContainerStyle>
</ListView>
And the ScrollViewer can move to the vertical offset of the top of the ListViewItem.
private void OnSelected(object sender, RoutedEventArgs e)
{
ScrollViewer scrollViewer = GetScrollViewer(uiListView) as ScrollViewer;
ListViewItem listViewItem = (ListViewItem)e.Source;
Point offset = listViewItem.TranslatePoint(new Point(), scrollViewer);
scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset + offset.Y);
}

Resources