Is there an event when a listbox item is created from an itemtemplate?

Jun 23, 2011 at 5:55 PM

I want to bind a listbox to a data structure in the window's resources dictionary, so all controls in the window can find the data structure. I cannot bind it declaratively when I define the window content, because it seems that the Resource dictionay has not yet been populated. But I can assign the listbox's itemssource to the data structure stored in the resource dictionary in the on_loaded event handler attached to the listbox, then call $lb.Items.Refresh(), and the corerct rows appear. But there is a hitch in my giddyup. The listbox uses an Itemtemplate to create nested controls in each item (one such nested control is btn_update, another is lbl_Duration). After the call to $lb.Items.Refresh(), I try to walk the control tree using

$lb | get_children btn_update |% {$btn=$_;write-debug $btn}

and there are no children! But if I set the listbox's ItemsSource (declaratively) to a copy of the same data structure stored outside of the window resource dictionary, then the nested controls DO appear when walking the listbox.

Furthermore, in the on_loaded event handler, $lb.Items.Count is 0 before the assignment of ItemsSource and the Items.Refresh(), and the $lb.Items.Count is 4 after the assignment and refresh. So the Listbox's Items are correctly getting refreshed.

So it seems that refreshing a ListBox's Items collections in the ListBox's on_loaded event does not (at that time) create the child controls called out in the Itemtemplate. But visually, all of the nested controls appear on the screen when the window is created. So the creation of the child controls in an item template have to be taking place in a later step. Does anybody know if there is an event to which I can attach a handler, after the listbox creates nested child controls?

I'm trying to write a GUI that accepts a hash of structures Key->Command, puts them in an ItemControl that displays key, the command, a button "refresh", and a label "Duration". When the user presses the refresh button, a timer, a stopwatch, and a job are created (and recorded in the windows resource dictionary). The timer's on-tick script block will  update the Duration label with the stopwatch value, check the status of the job, and if complete, destroy the job, timer, and stopwatch. This is very much an extension of the BackgroundJobExample.ps1 that I found on the Web. The original uses one job and one label - I'm trying to extend this to multiple jobs presented in a listbox.  

Coordinator
Jun 23, 2011 at 10:16 PM

If I read this correctly, you're asking:

Why does a named control exist as a variable (i.e. $myControl) in On_Loaded if its already declared, but doesn't if you create it and then refresh the items collection?

The answer to this is that we're doing a decent amount of little magic to make all of the naming stuff work.  This magic will peek down to all of the controls from a point, and declare named variables for them.  Since you're creating your new controls and adding them after this, you don't have the variables.

You can easily get them by doing this:

After you've added your controls, run:

. Initialize-EventHandler

This will give you named variables for each of your controls if they are added in On_Loaded

As the name implies, Initialize-EventHandler runs just before your code in every event handler, so you can also just know that the next time a button is clicked, the variables will exist.

Hope this helps,

James

 

 

 

 

Jun 24, 2011 at 5:09 AM

Umm, sorry , no, that's not what I'm asking. But thanks, I didn't even know that event names were being created for me - I was always walking the tree with Get-ChildControl to get them.  So that's a big plus, thanks!

Let me ask it this way. Why does a child control created by an itemtemplate NOT exist immediatly after a ListBox's itemsource is set and it's Item's.Refresh() method called?

I boiled it down as small as I can. The first window works, the second does not work. In the second example, in the listbox on_loaded, walking the listbox for childcontrols comes up empty. (but it works in the first example)


$data=@{a=1;b=2;c=3}
new-window {
  Listbox -Name lb -ItemsSource {$data} -ItemTemplate {StackPanel -Orientation "Horizontal" -Name "sp" {Label -Name 'lbl'; Button -Name 'Btn'}`
   | ConvertTo-DataTemplate -binding @{'lbl.Content' = 'Key';"btn.Content" = "Value"}}`
    -On_Loaded {
      $lcllb=$_.Source
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $lcllb.Name, $lcllb.Items.Count)
      $lcllb | Get-ChildControl btn |% { write-debug ("the button {0} has contents {1}" -f $_, $_.Contents)}
     }
} -show


$data2 = @{1='a';2='b';3='c'}
new-window {
  Listbox -Name lb2 -ItemTemplate {StackPanel -Orientation "Horizontal" -Name "sp2" {Label -Name 'lbl2'; Button -Name 'Btn2'}`
   | ConvertTo-DataTemplate -binding @{'lbl2.Content' = 'Key';"btn2.Content" = "Value"}}`
    -On_Loaded {
      $lcllb=$_.Source
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $lcllb.Name, $lcllb.Items.Count)
      $lcllb.ItemsSource = $data2
      $lcllb.Items.Refresh()
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $lcllb.Name, $lcllb.Items.Count)
      $lcllb | Get-ChildControl btn2 |% { write-debug ("the button {0} has contents {1}" -f $_, $_.Contents)}
      write-debug ("Even though the count is now 3, and visually the controls are rendered, the buttons were not there for Get-Childcontrol to find!")
   }
} -show

 

Coordinator
Jun 24, 2011 at 5:23 AM

Ok. It looks like you're observing a breaking change between WPK and ShowUI.

In WPK, Get-ChildControl drilled way down, and very inefficently.  This means that most scripts got slow because, as you did, they just had a lot of Get-ChildControls. 

In ShowUI, it drills down much faster, but the overhaul was risky, and it looks like you stumbled upon a spot where ShowUI doesn't quite follow WPK's old rules.  ShowUI's Get-ChildControl does not yet drill into Items.

I will probably fix this in the July release, but, in your case, it's much easier to simply walk thru $lcllb.Items

Hope this helps,

James

Coordinator
Jun 24, 2011 at 7:09 PM
Edited Jun 24, 2011 at 7:14 PM

OK, I got distracted by the last paragraph in your question, and asked James to answer because I think the timer and other stuff you're looking to create will have already been created by ShowUI, but James wrote that bit, so ... well anyway, to answer the first question: I can't find an event on the listbox that fires when the controls are generated.  However, you're right about the sequence of events.

It's not a bug in Get-ChildControl, the controls aren't there to find. That's how WPF databinding works. The ITEMS are there, but the controls haven't been created yet -- and the event handler will finish before they are created. 

There are two possible workarounds:

  1. Avoid setting ItemsSource and trying to read the generated controls in the same event handler.  Set the ItemsSource before-hand (i.e.: in Initialized). 
  2. Put an event handler on the StatusChange event of the ItemContentGenerator

For the first one, you just write this:

$data2=@{a=1;b=2;c=3}
new-window {
   Listbox -Name lb2 -ItemTemplate {StackPanel -Orientation "Horizontal" -Name "sp2" {
      Label -Name 'lbl2'; Button -Name 'Btn2'} | 
      ConvertTo-DataTemplate -binding @{'lbl2.Content' = 'Key';"btn2.Content" = "Value"}
   } -On_Initialized { 
      $this.ItemsSource = $data2
   } -On_Loaded {
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $this.Name, $this.Items.Count)
      $this | Get-ChildControl btn2 |% { write-debug ("the {0} has contents ({1})" -f $_, $_.Content)}
   }
} -show

Simple enough, but it really still relies on timing, and it doesn't account for Virtualization (see below). If you want to make sure you can set the ItemsSource later (or add items to it) and still have a chance to hook up your stuff to the generated items,  then you need to hook the ItemContainerGenerator so you can detect when it's about to generate and when it's done generating.

 

$data3=@{a=1;b=2;c=3}
new-window {
   Listbox -Name lb3 -ItemTemplate {StackPanel -Orientation "Horizontal" -Name "sp2" {
      Label -Name 'lbl3'; Button -Name 'Btn3'} | 
      ConvertTo-DataTemplate -binding @{'lbl3.Content' = 'Key';"btn3.Content" = "Value"}
   } -On_Loaded { listbox
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $this.Name, $this.Items.Count)
      Add-EventHandler $this.ItemContainerGenerator StatusChanged {
         Write-Verbose "ENTER StatusChanged! $($this.Status)"
         if($this.Status -eq "ContainersGenerated") {
            $i = 0
            do {
               $listItem = $this.ContainerFromIndex($i++)
               $listItem | Get-ChildControl btn3 |% { write-debug ("the {0} has contents ({1})" -f $_, $_.Content)}
            } while($listItem)
         }
         Write-Verbose "EXIT StatusChanged!"
      }
      $this.ItemsSource = $data3
   } 
} -show

 

Beauty, right?

One note: if you're watching the DEBUG output, you'll see a message every time the StatusChanged event gets handled, because our new EventHandler writes a DEBUG message whenever it handles an event on an object that's NOT a UI element (because some of the magic even handler variables won't work).  You can obviously ignore that message, since it's exactly what you expected ;-)

Ok, I'm going to go one step further, because you don't REALLY have to iterate with ContainerFromIndex, because our Get-ChildControl will take care of that for you:

$data4=@{a=1;b=2;c=3}
new-window {
   Listbox -Name lb4 -ItemTemplate {StackPanel -Orientation "Horizontal" -Name "sp2" {
      Label -Name 'lbl4'; Button -Name 'Btn4'} | 
      ConvertTo-DataTemplate -binding @{'lbl4.Content' = 'Key';"btn4.Content" = "Value"}
   } -On_Loaded { listbox
      write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $this.Name, $this.Items.Count)
      Add-EventHandler $this.ItemContainerGenerator StatusChanged {
         Write-Verbose "ENTER StatusChanged! $($this.Status)"
         if($this.Status -eq "ContainersGenerated") {
            $lb4 | Get-ChildControl btn4 |% { write-debug ("the {0} has contents ({1})" -f $_, $_.Content)}
         }
         Write-Verbose "EXIT StatusChanged!"
      }
      $this.ItemsSource = $data4
   } 
} -show

Anyway ... you should be careful with that, because if you're in a virtualized panel (like a listbox) those controls get created and destroyed periodically. Try this one, and scroll up and down to the top and bottom while you watch the verbose stream by in the console:

# Using a sorted distionary to preserve the order in the listbox
# Partly because the "VERBOSE" debugging messages make no sense otherwise
$data4 = New-Object "System.Collections.Generic.SortedDictionary[[String],[Int]]" 
$data4.a=1;$data4.b=2;$data4.c=3;$data4.d=4;$data4.e=5;$data4.f=6;$data4.g=7;$data4.h=8;$data4.i=9;$data4.j=10;$data4.k=11;$data4.l=12;$data4.m=13;$data4.n=15;$data4.o=16

Listbox -Name lb4 -Height 60 -ItemTemplate {
   StackPanel -Orientation "Horizontal" -Name "sp2" {
      Label -Name 'lbl4'; Button -Name 'Btn4'
   } |  ConvertTo-DataTemplate -binding @{'lbl4.Content' = 'Key';"btn4.Content" = "Value"}
} -On_Loaded { 
   write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $this.Name, $this.Items.Count)
   Add-EventHandler $this.ItemContainerGenerator StatusChanged {
      Write-Verbose "ENTER StatusChanged! $($this.Status)"
      if($this.Status -eq "ContainersGenerated") {
         $lb4 | Get-ChildControl btn4 |% { write-verbose ("the {0} has contents ({1})" -f $_, $_.Content)}
      }
      Write-Verbose "EXIT StatusChanged!"
   }
   $this.ItemsSource = $data4
} -Show

 

Hopefully, when you run that, you can see how when you're at the bottom, only the bottom few exist, and when you're at the top, only the top few exist. If you're relying on this mechanism to hook things, you need to make sure you use the StatusChanged and NOT the simpler method above, but you also have to make sure that you're not re-hooking things that were already hooked.  You have to be ESPECIALLY careful if you're using VirtualizingStackPanel.VirtualizationMode="Recycling" because then WPF actually reuses the controls for different "items" ... :-)

Jun 25, 2011 at 3:39 PM

That is an awesome reply! I didn't know the child-controls weren't permanent, but when you consider a listbox may have thousands of items, keeping a copy of every child-control in memory would be wasteful. Since I need the "Key" added to the Button's tag, I've changed my approach to use the ItemContainerGenerator StatusChanged event, tagging each (generated) button in the event handler, and adding a Click event handler to the button (after first removing any existing Click event hander, the syntax of which I'm still struggling with). This is a great solution to my problem.

Your example, and my implementation of it, both produce what seems to be a benign DEBUG message: "DEBUG: Cannot process argument transformation on parameter 'Control'. Cannot convert the "System.Windows.Controls.ItemContainerGenerator" value of type "System.Windows.Controls.ItemContainerGenerator" to type "System.Windows.DependencyObject"."  I'm running the June 6 drop. Just wondering what the message might mean...

BTW - instead of using a sorted dictionary, I added a SortDescription in my "real" application, because I want to sort the items according to a field in the item's value structure.  I tried it (sorting on Key Descending) with your example (using the old $data=}), and it works fine...@{a=1;b=2;c=3

$lb4.Items.SortDescriptions.Add((New-Object -TypeName System.ComponentModel.SortDescription -ArgumentList 'Key','Descending'))

Full example:

$data5=@{a=1;b=2;c=3}

Listbox -Name lb5 -Height 60 -ItemTemplate {
   StackPanel -Orientation "Horizontal" -Name "sp2" {
      Label -Name 'lbl5'; Button -Name 'Btn5'
   } |  ConvertTo-DataTemplate -binding @{'lbl5.Content' = 'Key';"btn5.Content" = "Value"}
} -On_Loaded { 
   write-debug ("in ListBox Loaded handler, ListBox name/count is {0} / {1}" -f $this.Name, $this.Items.Count)
   $lb5.Items.SortDescriptions.Add((New-Object -TypeName System.ComponentModel.SortDescription -ArgumentList 'Key','Descending'))
   Add-EventHandler $this.ItemContainerGenerator StatusChanged {
      Write-Verbose "ENTER StatusChanged! $($this.Status)"
      if($this.Status -eq "ContainersGenerated") {
         $lb5 | Get-ChildControl Btn5 |% { write-verbose ("the {0} has contents ({1})" -f $_, $_.Content)}
         #ToDo: There is probably a better way to add each Items' "key" as the tag in the button
         $labels= $lb | Get-ChildControl lbl5  
         $buttons= $lb | Get-ChildControl Btn5
         for ($i=0; $i -lt $buttons.Count; $I++) {
           write-debug ("button {0} gets tagged with {1}" -f $buttons[$i], $labels[$i])
           # Tag the button with the items' "key" (can be found on the lbl5 content, which I'm using, but probably there's a better place to get it from)
           $buttons[$i].tag = $labels[$i].Content
           # Add an event handler to the button. If the button control is being re-used by the ItemContainerGenerator, it may have a Click event, so remove it first
           if ($buttons[$i].GetType().GetEvent('Click',[Reflection.BindingFlags]"IgnoreCase, Public, Instance")) {
             # ToDo: Remove-Event $buttons[$i]_Click -ea SilentlyContinue
           } 
           #
           Add-EventHandler $buttons[$i] Click {}#Scriptblock goes here}
         }
      }
      
      Write-Verbose "EXIT StatusChanged!"
   }
   $this.ItemsSource = $data5
} -Show

Coordinator
Jun 25, 2011 at 6:32 PM
whertzing56 wrote:

Your example, and my implementation of it, both produce what seems to be a benign DEBUG message: "DEBUG: Cannot process argument transformation on parameter 'Control'. Cannot convert the "System.Windows.Controls.ItemContainerGenerator" value of type "System.Windows.Controls.ItemContainerGenerator" to type "System.Windows.DependencyObject"."  I'm running the June 6 drop. Just wondering what the message might mean...

I answered that question ;-)

Jaykul wrote:

One note: if you're watching the DEBUG output, you'll see a message every time the StatusChanged event gets handled, because our new EventHandler writes a DEBUG message whenever it handles an event on an object that's NOT a UI element (because some of the magic even handler variables won't work).  You can obviously ignore that message, since it's exactly what you expected ;-)

It's totally harmless. It comes from our ..\ShowUI\WPF\Initialize-EventHandler.ps1 script.