Search code examples
c#wpfstoryboardfill

Shape Fill color update stops Storyboard blinking animation


My WPF application is a monitor for production systems. The main window displays one of 3 Controls showing connection status (DOWN, LOADING, or UP). The UP Control has several high-level Button shapes (an Ellipse in below example), representing various elements of the production system (Router, Config, Internal Connections, External Connections, ...). The color of the object reflects the "worst" status of its children (or grandchildren, or great-grandchildren ...). It will also flash/blink (via Storyboard animation) if one of the children changes status.

Each Button shape maps to a ColorStatus object:

    public class ColorStatus: INotifyPropertyChanged
    {
        private eStatusColor StatusColor {get; set;}
        public bool          IsFlashing  {get; set;}
    
          // ...
    }

eStatusColor is an enum (INACTIVE, NORMAL, WARNING, CRITICAL), in which I use the Converter Level2Color_Converter() to change it to a SolidColorBrush (grey/green/yellow/red), and apply it to the shape Fill. The bool IsFlashing is used to trigger an animation on the Fill color (will go from current color to StatusBlinked, which is black, then back to current color, over a span of about 1 second). Here is an example Button shape (Router Oval):

                <!-- Router Oval -->
                <Button Grid.Column="1"             Margin="0,3"
                        VerticalAlignment="Center"  HorizontalAlignment="Center"
                        Focusable="False"           ToolTip="Open Router Window"
                        Command="{Binding OpenRouterWindow_Command}" >
                    <Button.Template>
                          <!-- This part turns off button borders/on mouseover animations -->
                        <ControlTemplate TargetType="Button">
                            <ContentPresenter Content="{TemplateBinding Content}"/>
                        </ControlTemplate>
                    </Button.Template>
                    <Grid>
                        <Ellipse HorizontalAlignment="Center"  VerticalAlignment="Center" 
                                 Height="60"                   Width="155"
                                 Stroke="Black"                StrokeThickness="2"
                                 Fill="{Binding RouterStatus.StatusColor, 
                                       Converter={local:Level2Color_Converter}}">
                            <Ellipse.Style>
                                <Style TargetType="{x:Type Ellipse}">
                                    <Style.Triggers>
                                        <DataTrigger Binding="{Binding RouterStatus.IsFlashing}" Value="True">
                                            <DataTrigger.EnterActions>
                                                <BeginStoryboard Name="Router_storyboard">
                                                    <BeginStoryboard.Storyboard>
                                                        <Storyboard>
                                                            <ColorAnimation To="{StaticResource StatusBlinked}" 
                                                                            Storyboard.TargetProperty="(Path.Fill).(SolidColorBrush.Color)" 
                                                                            AutoReverse="True"
                                                                            RepeatBehavior="Forever"
                                                                            Duration="0:0:0.5"/>
                                                        </Storyboard>
                                                    </BeginStoryboard.Storyboard>
                                                </BeginStoryboard>
                                            </DataTrigger.EnterActions>
                                        </DataTrigger>
                                        <DataTrigger Binding="{Binding RouterStatus.IsFlashing}" Value="False">
                                            <DataTrigger.EnterActions>
                                                <StopStoryboard BeginStoryboardName="Router_storyboard"/>
                                            </DataTrigger.EnterActions>
                                        </DataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </Ellipse.Style>
                        </Ellipse>
                        <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" 
                                   FontFamily="Arial"         FontSize="13"
                                   Foreground="White"         Text="ROUTER">
                            <TextBlock.Effect>
                                <DropShadowEffect/>
                            </TextBlock.Effect>
                        </TextBlock>
                    </Grid>
                </Button>

The problem is, if the object is already blinking and you change the color (say, from yellow to red), it turns off the storyboard animation (even though the bool IsFlashing is still true).

In manual testing (I had a test window with buttons to force color/flashing status changes), I solved this in the following way:

            if (mockup_IsBlinking)
            {
                  // Toggle is blinking to keep it blinking
                mockup_IsBlinking = false;
                mockup_IsBlinking = true;
            }

That is, if it was flashing, turn flashing off then on again to restart the animation.

For real testing (getting status updates from the application I am monitoring), I modified the ColorStatus to simulate the same method by modifying the setter:

    public class ColorStatus: INotifyPropertyChanged
    {
        private eStatusColor _StatusColor;
        public  eStatusColor  StatusColor {   get
                                              {
                                                  return _StatusColor;
                                              }
                                              set 
                                              { 
                                                  _StatusColor = value; 
                                                  if (IsFlashing)
                                                  {
                                                      IsFlashing = false;
                                                      IsFlashing = true;
                                                  }
                                              }     
                                          }
        public bool         IsFlashing    {get; set;}
    
          // ...
    }

BUT, when I connect to the actual application I am monitoring, I believe the blast of children status updates come in so fast that the animation gets out of sync (we get 135 children adds as NORMAL, which causes top-level Button shape from INACTIVE to NORMAL with IsFlashing=false, then actual child status updates come in, with 3 NORMAL to WARNING with IsFlashing=true and 27 NORMAL to CRITICAL with IsFlashing=true). When I start the monitor, ~ 1/5 of the time the top level flashing is correct (blinking), 4/5 of the time it is incorrect (IsFlashing is true, but storyboard animation is not working).

Note, there is not fixed order in the receipt of children statuses (we get the in the order they are sent from the production app). Also note, this is a multi-threaded app (reader thread enqueues message to message handler thread, which updates the ViewModel, and GUI updates via INotifyPropertyChanged).

Is there a way to fix this such that the Storyboard animation continues if IsFlashing is true, regardless of Fill color change? (or not get out of synch on color change?)


Solution

  • Originally, I used Clemens comment, of putting a black Ellipse behind the it, and tying the animation to the Opacity, while keeping color tied to Fill. This is good if there are but a few items blinking.

    But with so many items blinking, there seemed to be a performance hit (at least in debugger), plus all items were "out of sync" as they enter animations at different times.

    So, I did away with the animation all together. Instead, I started a Heartbeat thread, which every second, toggles a bool value FlashMaster. In my .xaml, I use an Elipse.Fill MultiBinding:

    <Ellipse.Fill>
        <MultiBinding Converter="{StaticResource LevelBoolFlash2Color_Converter}">
            <Binding Path="MrGWColorStatus.StatusColor"/>
            <Binding Path="MrGWColorStatus.IsFlashing"/>
            <Binding Path="AppVM.FlashMaster"/>
        </MultiBinding>
    </Ellipse.Fill>
    

    where StatusColor is the status level (current color), IsFlashing is a bool denoting if it should be flashing, and FlashMaster is the bool that is toggled every second.

    And here is the Converter:

    /// <summary>
    /// A converter that takes in 
    ///   [0] StatusLevel enum 
    ///   [1] IsFlashing boolean 
    ///   [2] FlashMaster boolean 
    /// and converts it to the correct WPF brush for Normal/Warning/Critical/Inactive (if not Error bool), or StatusERROR if error.
    /// </summary>
    public class LevelBoolFlash2Color_Converter   : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            SolidColorBrush returnValue;
    
            if ( (bool)values[1] && (bool)values[2])
            {
                  // Blink is True, so use StatusBlinked (black) instead of using eStatusColor
                returnValue = new SolidColorBrush((Color)Application.Current.FindResource("StatusBlinked"));
            }
            else
            {
                  // Use the status color
                switch ((eStatusColor)values[0])
                {
                    case eStatusColor.NORMAL:   returnValue = new SolidColorBrush((Color)Application.Current.FindResource("StatusNormal"  )); break;
                    case eStatusColor.WARNING:  returnValue = new SolidColorBrush((Color)Application.Current.FindResource("StatusWarning" )); break;
                    case eStatusColor.CRITICAL: returnValue = new SolidColorBrush((Color)Application.Current.FindResource("StatusCritical")); break;
                    default:                    returnValue = new SolidColorBrush((Color)Application.Current.FindResource("StatusInactive")); break;
                }
            }
    
            return returnValue;
        }
    
        public object[] ConvertBack(object value, Type[] targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
    

    where StatusBlinked is Black, .NORMAL is Green, .WARNING is Yellow, .CRITICAL is Red, and default is Grey