Notification when a ListViewItem scrolls into view - wpf

The following code is supposed to scroll an item into view and set focus to the first child control in the template:
lv.ScrollIntoView(lv.SelectedItem);
var lvi = lv.SelectedListViewItem();
//get the item's template parent
var templateParent = lvi.GetFrameworkElementByName<ContentPresenter>();
if (templateParent != null) <--but it's always null
{
var ctrl = templateParent.FindVisualChildren<FrameworkElement>().First();
ctrl.Focus();
}
The problem is that if the ListViewItem is not visible, then templateParent is null, and this code doesn't work. And of course this code is only useful when the item isn't already visible.
Is there a way to scroll the item into view and then be notified when it has come into view so that the template will be non-null so that the ctrl.Focus() code would execute?

You could handle the RequestBringIntoView event. Please refer to the following sample code.
public MainWindow()
{
InitializeComponent();
lv.ItemsSource = Enumerable.Range(1, 100);
lv.SelectedItem = 90;
lv.ScrollIntoView(lv.SelectedItem);
lv.RequestBringIntoView += Lv_RequestBringIntoView;
}
private void Lv_RequestBringIntoView(object sender, RequestBringIntoViewEventArgs e)
{
var container = lv.ItemContainerGenerator.ContainerFromItem(lv.SelectedItem);
if (container != null)
{
//...
}
}

Related

Attach to Event of Sibling Control in Custom WPF Control Initialization

I am trying to create a WPF custom slider control that acts as a scrollbar for a Listview. I'm doing this by putting the name of the listview in the Tag attribute of my custom slider and then using the slider's OnValueChange event to scroll the listview. This works great, however, when I scroll in the listview with my mousewheel the slider doesn't move. What I need is a way to attach a method to the listview's MouseWheel event when my custom slider initializes. Here is what I've tried:
Custom slider class:
public class LabeledScrollbar : Slider
{
public override void EndInit()
{
var listbox = (ListBox)this.FindName(this.Tag.ToString());
if (listbox != null)
{
listbox.MouseWheel += new MouseWheelEventHandler(this.OnMouseWheel);
}
base.EndInit();
}
protected void OnMouseWheel(object sender, MouseWheelEventArgs e)
{
this.Value += 5;
}
protected override void OnValueChanged(double oldValue, double newValue)
{
var listBox = (ListBox)this.FindName(this.Tag.ToString());
var collection = (CollectionView)CollectionViewSource.GetDefaultView(listBox.ItemsSource);
if (newValue == this.Maximum)
{
if (VisualTreeHelper.GetChildrenCount(listBox) > 0)
{
var chrome = VisualTreeHelper.GetChild(listBox, 0);
var scrollView = (ScrollViewer)VisualTreeHelper.GetChild(chrome, 0);
scrollView.ScrollToTop();
}
}
else
{
var index = (collection.Count - 1) - (int)Math.Floor(newValue);
var selectedItem = collection.GetItemAt(index);
listBox.ScrollIntoView(selectedItem);
}
}
}
XAML:
<ListView x:Name="listViewCategories">
...
</ListView>
<local:LabeledScrollbar x:Name="categoryScrollbar" Orientation="Vertical" TickPlacement="BottomRight" Tag="listViewCategories"></local:LabeledScrollbar>
While it seems like OnMouseWheel should fire when the I scroll in the listview, it's not happening and I haven't been able to find anything else to try. Is there a way to do what I want in WPF? I know I could put a method in the code behind of my view to make the MouseScroll event of the listview move the slider, but I was hoping to encapsulate as much of the logic for the slider in the slider class as possible.
So it seems that the trick was to use PreviewMouseWheel instead of MouseWheel. For future reference here is my current class:
/// <summary>
/// This class creates a custom control that can be used as a scrollbar for a listbox that displays the current
/// group visible in the listbox on a small label next to the slider thumb.
///
/// To use it, set the Tag value to the name of the listbox the scollbar will be controlling.
/// </summary>
public class LabeledScrollbar : Slider
{
//Tracks control initialization to ensure it only gets loaded once
private bool initialized = false;
//The listview this slider will control
private ListView listView;
public LabeledScrollbar(): base()
{
this.Loaded += LabeledScrollbar_Loaded;
}
void LabeledScrollbar_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
//The tag must be set to the name of a listbox
listView = (ListView)this.FindName(this.Tag.ToString());
if (listView != null && !this.initialized)
{
//Make sure that the mouse wheel event in the linked listbox is handled
listView.PreviewMouseWheel += (s, ev) =>
{
if (ev.Delta > 0)
this.Value += 3;
else
this.Value -= 3;
};
//Move scrollbar and list to the top if the collection changes
((INotifyCollectionChanged)listView.Items).CollectionChanged += (s, ev) =>
{
this.Maximum = ((ItemCollection)listView.Items).Count - 1;
this.Value = this.Maximum;
};
//Get the max value of the slider by checking the tag value and looking up the associated listbox
this.Maximum = ((ItemCollection)listView.Items).Count - 1;
this.Value = this.Maximum;
this.initialized = true;
}
}
protected override void OnValueChanged(double oldValue, double newValue)
{
//Refresh the tickbar so that it will render for a new value
InvalidateTickbar();
//Scroll the list box to the correct location
ScrollToIndex(newValue);
}
private void ScrollToIndex(double newValue)
{
if (newValue == this.Maximum)
{
//ScrollIntoView method does not scroll to the top so
//we need to access the scrollview to move the slider to the top
if (VisualTreeHelper.GetChildrenCount(listView) > 0)
{
var chrome = VisualTreeHelper.GetChild(listView, 0);
var scrollView = (ScrollViewer)VisualTreeHelper.GetChild(chrome, 0);
scrollView.ScrollToTop();
}
}
else
{
var collection = (CollectionView)CollectionViewSource.GetDefaultView(listView.ItemsSource);
var index = (collection.Count - 1) - (int)Math.Floor(newValue);
var selectedItem = collection.GetItemAt(index);
listView.ScrollIntoView(selectedItem);
}
}
private void InvalidateTickbar()
{
//update the tickbar for the new position
if (VisualTreeHelper.GetChildrenCount(this) > 0)
{
var firstChild = VisualTreeHelper.GetChild(this, 0);
if (VisualTreeHelper.GetChildrenCount(firstChild) > 0)
{
var secondChild = (CustomTickBar)VisualTreeHelper.GetChild(firstChild, 0);
secondChild.InvalidateVisual();
}
}
}
}

WPF Context Menu

TextBox has a default context menu. I would like to add an item to it. OK, that means cloning the default one, and adding an extra item to that.
I'd like to reuse some code here. I have five textboxes. Each needs the additional item on its context menu. The item needs act on the textbox that was clicked. I know "copy and paste" is the recommended method of code reuse in WPF, but if possible I'd prefer not to define five menus in XAML and five commands in the code behind.
Is there any reasonably clean and quick way to do this in WPF?
public partial class MyGhastlyView
{
/* blah blah */
private void MenuCut_Click(object sender, RoutedEventArgs e)
{
try
{
(sender as MenuItem).GetPlacementTarget<TextBox>().Cut();
}
catch (Exception)
{
}
}
/* blah blah */
}
public static class FurshlugginerExtensions
{
public static bool TryGetPlacementTarget<TTargetType>(this MenuItem mi,
out TTargetType target) where TTargetType : class
{
target = null;
var cm = mi.GetContextMenu();
if (null != cm)
{
target = cm.PlacementTarget as TTargetType;
}
return null != target;
}
public static TTargetType GetPlacementTarget<TTargetType>(this MenuItem mi)
where TTargetType : class
{
var cm = mi.GetContextMenu();
return (cm == null)
? null
: cm.PlacementTarget as TTargetType;
}
public static ContextMenu GetContextMenu(this MenuItem mi)
{
var logicalParent = LogicalTreeHelper.GetParent(mi);
if (logicalParent is ContextMenu)
{
return logicalParent as ContextMenu;
}
else if (logicalParent is MenuItem)
{
return (logicalParent as MenuItem).GetContextMenu();
}
return null;
}
}
UPDATE
What I'm looking for turns out to be a RoutedUICommand, with some futzing around in XAML. It knows what you clicked on (with some Kafkaesque exceptions due to event bubbling -- but can just set the CommandParameter on the ContextMenu).
Unfortunately, ContextMenuOpening event will not work here. For whatever reason, TextBox does not expose its context menu, and is always null unless you set it with your own. Perhaps it simply pops a private menu on right mouse click.
Charles Petzold speaks about that with RichTextBox here. (Both TextBox and RichTextBox derive from TextBoxBase, which appears to define that behavior)
It seems you will have to create your own, and duplicate the existing items.
Several articles demonstrate exactly this, like the one here.
Hope this helps.
EDIT:
However if you insist on editing the current menu, it appears someone has done so here (using an extension method and reflection).
After further investigation of the above attempt, it seems that the author is creating an instance of an EditorContextMenu (private class which derives from ContextMenu in System.Windows.Documents) and assigning it to the TextBox ContextMenu property, then adding the parameter menu items to the newly created menu. In effect, overriding the current menu. While you do get the original implementation, I am not sure I would favor this solution.
EDIT 2:
The following code will create only one instance of custom menu, bind Ctrl-D to the textboxes, along with the correlating ContextMenu item.
public static RoutedCommand ItemActionCommand = new RoutedCommand();
public MainWindow()
{
InitializeComponent();
CommandBinding commandBinding = new CommandBinding(ItemActionCommand, new ExecutedRoutedEventHandler(ItemActionCommandEventHandler));
KeyBinding keyBinding = new KeyBinding(ItemActionCommand, new KeyGesture(Key.D, ModifierKeys.Control));
MenuItem item = new MenuItem();
item.Click += CustomContextMenuItem_Click; // not really necessary
item.Header = "Custom Menu Item";
item.InputGestureText = "Ctrl+D";
item.Command = ItemActionCommand;
ContextMenu menu = new ContextMenu();
menu.Items.Add(item);
Grid container = new Grid();
this.Content = container;
for (int i = 0; i < 5; i++)
container.Children.Add(this.CreateTextBox("Value: " + i.ToString(), (i + 1) * 30.0d, menu, commandBinding, keyBinding));
}
private void ItemActionCommandEventHandler(object sender, ExecutedRoutedEventArgs e)
{
TextBox textBox = e.Source as TextBox;
Debug.Assert(textBox != null);
// perform actions against textbox here
}
private void CustomContextMenuItem_Click(object sender, RoutedEventArgs e)
{
MenuItem item = sender as MenuItem;
Debug.Assert(item != null);
TextBox textBox = ((ContextMenu)item.Parent).PlacementTarget as TextBox;
Debug.Assert(textBox != null);
// no need to do anything here since the command handler above will fire
// but for the sake of completeness
}
private TextBox CreateTextBox(string text, double topOffset, ContextMenu menu, CommandBinding commandBinding, KeyBinding keyBinding)
{
TextBox textbox = new TextBox();
textbox.HorizontalAlignment = HorizontalAlignment.Center;
textbox.VerticalAlignment = VerticalAlignment.Top;
textbox.Margin = new Thickness(0.0d, topOffset, 0.0d, 0.0d);
textbox.CommandBindings.Add(commandBinding);
textbox.InputBindings.Add(keyBinding);
textbox.ContextMenu = menu;
textbox.Width = 150.0d;
textbox.Height = 25.0d;
textbox.Text = text;
return textbox;
}
Screenshot:
It is possible with an AttachedProperty and the handling of the ContextMenuOpening event. Look here and here. Should take around 100 lines of code and one line in xaml.
For completenes sake:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace WpfApplication1
{
public class CustomMenuAction
{
public static bool GetHasMenuItemAction(DependencyObject obj)
{
return (bool)obj.GetValue(HasMenuItemActionProperty);
}
public static void SetHasMenuItemAction(DependencyObject obj, bool value)
{
obj.SetValue(HasMenuItemActionProperty, value);
}
// Using a DependencyProperty as the backing store for MyProperty. This enables animation, styling, binding, etc...
public static readonly DependencyProperty HasMenuItemActionProperty =
DependencyProperty.RegisterAttached("HasMenuItemAction", typeof(bool), typeof(CustomMenuAction), new PropertyMetadata(default(bool),OnPropertyChanged));
private static void OnPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if((bool)e.NewValue)
{
var textbox = d as TextBox;
if(textbox != null)
{
textbox.ContextMenu = GetCustomContextMenu();
textbox.ContextMenuOpening += textbox_ContextMenuOpening;
}
}
}
private static ContextMenu GetCustomContextMenu()
{
var contextMenu = new ContextMenu();
var standardCommands = GetStandardCommands();
foreach (var item in standardCommands)
{
contextMenu.Items.Add(item);
}
return contextMenu;
}
private static IList<MenuItem> GetStandardCommands()
{
//From https://stackoverflow.com/a/210981/3411327
List<MenuItem> standardCommands = new List<MenuItem>();
MenuItem item = new MenuItem();
item.Command = ApplicationCommands.Cut;
standardCommands.Add(item);
item = new MenuItem();
item.Command = ApplicationCommands.Copy;
standardCommands.Add(item);
item = new MenuItem();
item.Command = ApplicationCommands.Paste;
standardCommands.Add(item);
return standardCommands;
}
static void textbox_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
//From MSDN example: http://msdn.microsoft.com/en-us/library/bb613568.aspx
var textbox = e.Source as TextBox;
ContextMenu cm = textbox.ContextMenu;
foreach (MenuItem mi in cm.Items)
{
if ((String)mi.Header == "Item4") return;
}
MenuItem mi4 = new MenuItem();
mi4.Header = "Item4";
mi4.Click += (o, args) =>
{
var menuItem = o as MenuItem;
MessageBox.Show(menuItem.Header.ToString(), textbox.Text);
};
textbox.ContextMenu.Items.Add(mi4);
}
}
}
<TextBox namespace:CustomMenuAction.HasMenuItemAction="True"></TextBox>

WPF cannot get ItemContainerGenerator.ContainerFromItem to work

I have looked here and here and many other places, but I just can't seem to get the ItemContainerGenerator.ContainerFromItem method to work on a WPF TreeView! I have tried to pass in the actual item I want to see, but not getting anywhere with that, I just tried to get the first item in my TreeView. Here's my sample code:
private static bool ExpandAndSelectItem(ItemsControl parentContainer, object itemToSelect)
{
// This doesn't work.
parentContainer.BringIntoView();
// May be virtualized, bring into view and try again.
parentContainer.UpdateLayout();
parentContainer.ApplyTemplate();
TreeViewItem topItem = (TreeViewItem)parentContainer.ItemContainerGenerator.ContainerFromItem(parentContainer.Items[0]);
// Can't find child container unless the parent node is Expanded once
if ((topItem != null) && !topItem.IsExpanded)
{
topItem.IsExpanded = true;
parentContainer.UpdateLayout();
}
}
As you can see, I have tried to call many "updating" methods to try to get the TreeView to be "visible" and "accessible". The Catch-22 seems to be that you can't use ContainerFromItem() unless the first TreeViewItem is expanded, but I can't grab the TreeViewItem to Expand it until ContainerFromItem() works!
Another funny thing that is happening is this: When I open this window (it is a UserControl), ContainerFromItem() returns nulls, but if I close the window and open it back up, ContainerFromItem() starts returning non-nulls. Is there any event I should be looking for or forcing to fire?
Turns out the event I was looking for was "Loaded". I just attached an event handler onto my treeview in the XAML, and called my logic in that event handler.
<TreeView x:Name="MyTreeView"
Margin="0,5,0,5"
HorizontalAlignment="Left"
BorderThickness="0"
FontSize="18"
FontFamily="Segoe WP"
MaxWidth="900"
Focusable="True"
Loaded="MyTreeView_Load">
...
</TreeView>
The event handler:
private void MyTreeView_Load(object sender, RoutedEventArgs e)
{
ShowSelectedThing(MyTreeView, ThingToFind);
}
// Gotta call the TreeView an ItemsControl to cast it between TreeView and TreeViewItem
// as you recurse
private static bool ShowSelectedThing(ItemsControl parentContainer, object ThingToFind)
{
// check current level of tree
foreach (object item in parentContainer.Items)
{
TreeViewItem currentContainer = (TreeViewItem)parentContainer.ItemContainerGenerator.ContainerFromItem(item);
if ((currentContainer != null) && (item == ThingToFind)
{
currentContainer.IsSelected = true;
currentContainer.BringIntoView();
return true;
}
}
// item is not found at current level, check the kids
foreach (object item in parentContainer.Items)
{
TreeViewItem currentContainer = (TreeViewItem)parentContainer.ItemContainerGenerator.ContainerFromItem(item);
if ((currentContainer != null) && (currentContainer.Items.Count > 0))
{
// Have to expand the currentContainer or you can't look at the children
currentContainer.IsExpanded = true;
currentContainer.UpdateLayout();
if (!ShowSelectedThing(currentContainer, ThingToFind))
{
// Haven't found the thing, so collapse it back
currentContainer.IsExpanded = false;
}
else
{
// We found the thing
return true;
}
}
}
// default
return false;
}
Hope this helps someone. Sometimes in the real world, with demanding customers, weird requirements and short deadlines, ya gotta hack!
When the container generator's status is 'NotStarted' or 'ContainersGenerating', you can't find the container.
Use this method to find the container of data item.
private static async Task<TreeViewItem> FindItemContainer(ItemsControl itemsControl, object item)
{
var generator = itemsControl.ItemContainerGenerator;
if (generator.Status != GeneratorStatus.ContainersGenerated)
{
var tcs = new TaskCompletionSource<object>();
EventHandler handler = null;
handler = (s, e) =>
{
if (generator.Status == GeneratorStatus.ContainersGenerated)
{
generator.StatusChanged -= handler;
tcs.SetResult(null);
}
else if (generator.Status == GeneratorStatus.Error)
{
generator.StatusChanged -= handler;
tcs.SetException(new InvalidOperationException());
}
};
generator.StatusChanged += handler;
if (itemsControl is TreeViewItem tvi)
tvi.IsExpanded = true;
itemsControl.UpdateLayout();
await tcs.Task;
}
var container = (TreeViewItem)generator.ContainerFromItem(item);
if(container == null)
{
foreach (var parentItem in itemsControl.Items)
{
var parentContainer = (TreeViewItem)generator.ContainerFromItem(parentItem);
container = await FindItemContainer(parentContainer, item);
if (container != null)
return container;
}
}
return container;
}
private void Lv_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
ListView Lv = (ListView)sender;
Lv.UpdateLayout(); // 1.step
DependencyObject Dep = Lv.ItemContainerGenerator
.ContainerFromItem(Lv.SelectedItem);
((ListViewItem)Dep).Focus(); //2.step
}
I had come across this issue time ago and now again I got stuck with it for quite a while. Any MessageBox launch or an expand or dropdown on your particular control type, any of these do the job and start the ItemContainerGenerator. The .UpdateLayout() however is the right thing to do, before the .Focus(). Should be analogous for a Treeview, or one of its Items.

Getting the TreeViewItem on newly created items

There has to be a better way then the following for getting "childItem"
TaskItem task = (sender as Canvas).DataContext as TaskItem;
TaskItem child = Tasks.CreateTask("New task", task);
TreeViewItem item = treeView.ItemContainerGenerator.ContainerFromItem(task) as TreeViewItem;
item.UpdateLayout();
TreeViewItem childItem = null;
foreach (var i in item.GetDescendantContainers())
{
if (i.GetItem() == child)
childItem = i;
}
For some reason item.ItemGenerator.ContainerFromItem(child) does not work (must be due to the item having just been created)
Item container generation is asynchronous, so you cannot assume the container will exist as soon as the item was added. You will need to attach a handler to the ItemContainerGenerator.StatusChanged event so your code will be informed when container generation is complete.
Dr. WPF's blog entry "ItemsControl: 'G' is for Generator" has a good description of the problem and provides an example of using StatusChanged:
private void AddScooby()
{
_scooby = new Character("Scooby Doo");
Characters.Add(_scooby);
CharacterListBox.ItemContainerGenerator.StatusChanged
+= OnStatusChanged;
}
private void OnStatusChanged(object sender, EventArgs e)
{
if (CharacterListBox.ItemContainerGenerator.Status
== GeneratorStatus.ContainersGenerated)
{
CharacterListBox.ItemContainerGenerator.StatusChanged
-= OnStatusChanged;
ListBoxItem lbi = CharacterListBox.ItemContainerGenerator
.ContainerFromItem(_scooby) as ListBoxItem;
if (lbi != null)
{
lbi.IsSelected = true;
}
}
}

Scroll a new item in a ItemsControl into view

I have an ItemsControl that is databound to a ObservableCollection. I have this method in the code behind which adds a new model to the list. I would then like to scroll the new item (at the bottom of the list) into view.
I think the size of the ItemsControl is not yet updated when I am querying the size, since the ActualHeight before and after the addition of the model is the same. The effect of this code is to scroll to a point slightly above the new item.
How would I know what the new ActualHeight is going to be?
Here is my code:
ViewModel.CreateNewChapter();
var height = DocumentElements.ActualHeight;
var width = DocumentElements.ActualWidth;
DocumentElements.BringIntoView(new Rect(0, height - 1, width, 1));
I think you need to call BringIntoView on the item container, not the ItemsControl itself :
var container = DocumentElements.ItemContainerGenerator.ContainerFromItem(model) as FrameworkElement;
if (container != null)
container.BringIntoView();
EDIT: actually this doesn't work, because at this point, the item container hasn't been generated yet... You could probably handle the StatusChanged event of the ItemContainerGenerator. I tried the following code :
public static class ItemsControlExtensions
{
public static void BringItemIntoView(this ItemsControl itemsControl, object item)
{
var generator = itemsControl.ItemContainerGenerator;
if (!TryBringContainerIntoView(generator, item))
{
EventHandler handler = null;
handler = (sender, e) =>
{
switch (generator.Status)
{
case GeneratorStatus.ContainersGenerated:
TryBringContainerIntoView(generator, item);
break;
case GeneratorStatus.Error:
generator.StatusChanged -= handler;
break;
case GeneratorStatus.GeneratingContainers:
return;
case GeneratorStatus.NotStarted:
return;
default:
break;
}
};
generator.StatusChanged += handler;
}
}
private static bool TryBringContainerIntoView(ItemContainerGenerator generator, object item)
{
var container = generator.ContainerFromItem(item) as FrameworkElement;
if (container != null)
{
container.BringIntoView();
return true;
}
return false;
}
}
However it doesn't work either... for some reason, ContainerFromItem still returns null after the status changes to ContainersGenerated, and I have no idea why :S
EDIT : OK, I understand now... this was because of the virtualization : the containers are generated only when they need to be displayed, so no containers are generated for hidden items. If you switch virtualization off for the ItemsControl (VirtualizingStackPanel.IsVirtualizing="False"), the solution above works fine.

Resources