wpfxamlvisualstatemanager

VisualStateManager.GoToState method call does not change the state and display a popup window


I have a Control defined by a ControlTemplate shown below:

    <ControlTemplate x:Key="UXIconComboBoxControlTemplate" TargetType="{x:Type controls:UXIconComboBox}" >
    <ControlTemplate.Resources>
        <converters:IconComboBoxDropdownHorizontalOffsetMultiConverter x:Key="ComboBoxDropdownHorizontalOffsetConverter"/>
    </ControlTemplate.Resources>
    <Grid 
        x:Name="rootGrid"
        Width="{TemplateBinding HitAreaWidth}" 
        Height="{TemplateBinding HitAreaHeight}" 
        Background="{TemplateBinding Background}"
        >
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <controls:ExtendedHitAreaButton 
            Grid.Row="0"
            x:Name="PopupExpanderButton"  << Popup Button
            IsTabStop="True"
            AutomationProperties.AutomationId="UXIconComboBox"
            Style="{StaticResource PopupExpanderButtonStyle}">
        </controls:ExtendedHitAreaButton>
        <Popup 
            x:Name="IconComboBoxPopup"
            Grid.Row="1" 
            AutomationProperties.AutomationId="UXIconComboBoxPopup"
            Placement="Bottom"
            PlacementTarget="{Binding ElementName=PopupExpanderButton}"
            MinWidth="200"
            StaysOpen="False">
            <Popup.HorizontalOffset>
                <MultiBinding Converter="{StaticResource ComboBoxDropdownHorizontalOffsetConverter}">
                    <Binding ElementName="PopupExpanderButton" Path="ActualWidth"/>
                    <Binding ElementName="IconComboBoxPopup_ListBox" Path="ActualWidth"/>
                </MultiBinding>
            </Popup.HorizontalOffset>
            <ListBox 
                x:Name="IconComboBoxPopup_ListBox" 
                AutomationProperties.AutomationId="UXIconComboBoxListBox"
                ItemsSource="{Binding ItemsSource, RelativeSource={RelativeSource TemplatedParent}}"
                IsSynchronizedWithCurrentItem="True"
                Focusable="True"
                KeyboardNavigation.TabNavigation="Cycle"
                KeyboardNavigation.DirectionalNavigation="Cycle"    
                BorderThickness="0"
                ItemContainerStyle="{StaticResource ListBoxContainerStyle}"
                ItemTemplate="{Binding ItemTemplate, RelativeSource={RelativeSource TemplatedParent}}">
            </ListBox>

        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="PopupStates">
                <VisualState x:Name="PopupClosed">
                    <Storyboard>
                        <BooleanAnimationUsingKeyFrames Storyboard.TargetName="IconComboBoxPopup"
                                            Storyboard.TargetProperty="IsOpen">
                            <DiscreteBooleanKeyFrame KeyTime="0:0:0.25"
                                       Value="False" />
                        </BooleanAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames
                                        Storyboard.TargetName="rootGrid"
                                        Storyboard.TargetProperty="(Panel.Background)">
                            <DiscreteObjectKeyFrame KeyTime="0:0:0.25" Value="{DynamicResource BrushIconComboBox_Popup_Closed_Background}" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="PopupOpen">
                    <Storyboard>
                        <BooleanAnimationUsingKeyFrames Storyboard.TargetName="IconComboBoxPopup"
                                            Storyboard.TargetProperty="IsOpen">
                            <DiscreteBooleanKeyFrame KeyTime="0:0:0.25"
                                       Value="True" />
                        </BooleanAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames
                                        Storyboard.TargetName="rootGrid"
                                        Storyboard.TargetProperty="(Panel.Background)">
                            <DiscreteObjectKeyFrame KeyTime="0:0:0.25" Value="{DynamicResource BrushIconComboBox_Popup_Open_Background}" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>

        </Popup>
    </Grid>
</ControlTemplate>

I also have a Click event handler for the PopupExpanderButton as follows:

    /// <summary>
/// Handler for the ExtendedHitAreaButton click event
/// </summary>
/// <param name="sender">listBoxItem info</param>
/// <param name="e">routed event info</param>
public void PopupExpanderButton_Click(object sender, RoutedEventArgs e)
{
    Debug.WriteLine($"PopupExpanderButton_Click called - popup state {IconComboBoxPopup.IsOpen}");

    var popupState = IconComboBoxPopup.IsOpen ? PopupClosed : PopupOpen;

    var result = VisualStateManager.GoToState(this, popupState, true);

    e.Handled = true;
}

If I click on the PopupExpanderButton, the Click event above is executed and the Popup window is displayed. NOTE that the PopupExpanderButton is simply a subclass of Button that includes a HitTestCore function that calls PointHitTestResult.

Without moving the mouse away from over the PopupExpanderButton, if I click on the PopupExpanderButton again, the popup window gets a lost-focus event and closes as it should, but then the Click event above is executed, IconComboBoxPopup.IsOpen is false, the GoToState is executed with a PopupOpen value .... but the Popup window does not open. The result value of the GoToState call is always True. Changing the method to GoToElementState makes no difference.

If I keep clicking on the PopupExpanderButton, the Click event above is executed with a PopupOpen value, but the Popup window will not open again.

If I click away from the PopupExpanderButton to some other part of the dialog, and then go back and click the PopupExpanderButton, the Popup window WILL appear!!

Can anyone tell me why the GoToState method is not working after the first Click event?

Thanks in advance!!


Solution

  • It's recommended to attach the VisualStateManager.VisualStateGroups attached property to the root element of the ControlTemplate, which is the Grid in your case. It's currently attached to the Popup.
    It's also recommended that you don't set RoutedEventArgs.Handled to true. This would stop the RoutedEvent from traversing the element tree, which could introduce unexpected behavior.
    Usually client code of your control would expect such events to traverse. In your case a reason to stop this event would be if you want to convert the Button.Click event to a more meaningful IconComboBoxPopupOpened event. In this case you would first handle the Button.Click, mark it as handled to stop it and raise the IconComboBoxPopupOpened instead.

    The problem you are experienceing is becuase your visual states are not transitioned correctly. You forgot to transition the state from "PopupOpen" back to "PopupClosed".
    Currently, although the Popup is closed the control's state is still "PopipOpen" - that's why when trying to transition the state to "PopupOpen" does nothing, but VisualStateManager.GoToState returns true.
    The return value is always true when the transition was successful or the control is already in the desired state.

    Because you allow the Popup to close itself implicitly by losing focus, you must track the Popup.IsOpen for this particular case.
    In fact you must track every close event that is not explicitly triggered by the hosting control (e.g. by handling key gestures) and handle it by transitioning the visual state to "PopupClosed".
    Same applies for open events in case the Popup was opened by an unknown actor.

    MyCustomControl.cs

    // State property
    public bool IsIconComboBoxPopupOpen { get; private set; }
    
    // Set this property to 'true' right before this control 
    // explicitly opens or closes the Popup. 
    // The Popup.Closed and Popup.Opened handlers will reset it.
    private bool IsPopupOpenChangedInternally { get; set; }
    
    private Popup PART_IconComboBoxPopup { get; set; }
    
    protected override OnApplyTemplate()
    {
      base.OnApplyTemplate();
    
      this.PART_IconComboBoxPopup = GetTemplateChild("IconComboBoxPopup") as Popup;
      if (this.PART_IconComboBoxPopup is not null)
      {
        this.PART_IconComboBoxPopup.Opened += OnPART_IconComboBoxPopupOpened;
        this.PART_IconComboBoxPopup.Closed += OnPART_IconComboBoxPopupClosed;
      }
    }
    
    protected override OnPART_IconComboBoxPopupOpened(object sender, EventArgs e)
    {
      // Ignore event if Popup was opened or closed 
      // by this control e.g. by a KeyUp handler
      if (this.IsPopupOpenChangedInternally)
      {
        this.IsPopupOpenChangedInternally = false;
        return;
      }
    
      // Popup was opened by some event that was not triggered by this control.
      // We need to update this control's visual state accordingly.
      this.IsIconComboBoxPopupOpen = true;
      UpdatePopupStates();
    }
    
    protected override OnPART_IconComboBoxPopupClosed(object sender, EventArgs e)
    {
      // Ignore event if Popup was closed by this control e.g. by a KeyUp handler
      if (this.IsPopupOpenChangedInternally)
      {
        this.IsPopupOpenChangedInternally = false;
        return;
      }
    
      // Popup was closed by some event that was not triggered by this control.
      // We need to update this control's visual state accordingly.
      this.IsIconComboBoxPopupOpen = false;
      UpdatePopupStates();
    }
    
    private bool UpdatePopupStates()
    {
      var nextState = this.IsIconComboBoxPopupOpen 
        ? "PopupOpen"
        : "PopupClosed";
    
      VisualStateManager.GotoState(this, nextState, true);
    }
    
    public void PopupExpanderButton_Click(object sender, RoutedEventArgs e)
    {   
      // Flag that this control is the trigger of following Popup events
      this.IsPopupOpenChangedInternally = true;
    
      // Toggle visual state using the XOR operator
      this.IsIconComboBoxPopupOpen ^= true;
      UpdatePopupStates();
    
      // Avoid setting Handled to true. Allow traversal of RoutedEvents.
      //e.Handled = true;
    }
    
    // Example of this control explicitly closing the Popup
    protected void IconComboBoxPopup_PreviewKeyUp(object sender, KeyEventArgs e)
    {
      if (e.Key == Key.Escape)
      {
        // Flag that this control is the trigger of following Popup events
        this.IsPopupOpenChangedInternally = true;
    
        this.IsIconComboBoxPopupOpen = false;
        UpdatePopupStates();
      }
    }