Search code examples
c#wpfdata-bindingdependency-properties

How to bind to calculated property using multiple dependency properties


I'm trying to build a WPF UserControl that I can reuse throughout my project. It's basically this:

  • Horizontal line (called top line)
  • Text block - Horizontal line (bottom line)

When the window resizes and is too small, I only show the bottom line to save space. So I'd like to have these options:

  • Hide all lines
  • Show only the top line
  • Show only the bottom line

I'm using three dependency properties to indirectly set the visibility of each line:

  • Show lines (enables lines)
  • Can compress (enable showing top or bottom, depending on available space)
  • Show compressed (show bottom line when true, show top line when false)

Binding directly to the dependency properties works. Indirectly does not.

Now the question is whether I need to use:

  1. Flags like FrameworkPropertyMetadataOptions.AffectsRender for FrameworkPropertyMetadata
  2. PropertyChangedCallback when creating FrameworkPropertyMetadata for DependencyProperty.Register
  3. A value converter
  4. INotifyPropertyChanged

What I have tried

I attempted all four options above, but I don't understand it well enough yet to get one working.

I created normal properties that return a calculated value from the three dependency properties. These only use the default value set with DependencyProperty.Register, they don't update or even use the value set in the parent user control.

Current state

<StackPanel>
    <Border Height="1"
            Background="Black"
            Margin="0 10 0 0"
            Visibility="{Binding ShowTopLine, ElementName=Root, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"/>
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto"/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <TextBlock Grid.Column="0"
                   Text="{Binding Text, ElementName=Root, Mode=TwoWay, FallbackValue=Heading}"
                   Margin="0 10 10 0" />
        <Border Grid.Column="1" 
                Height="1" 
                Margin="0 10 0 0" 
                Background="Black" 
                VerticalAlignment="Center" 
                Visibility="{Binding ShowBottomLine, ElementName=Root, Mode=OneWay, Converter={StaticResource BooleanToVisibilityConverter}}"/>
    </Grid>
</StackPanel>

Relevant part of code-behind, showing one of three dependency properties:

internal partial class Heading
{
  public bool ShowBottomLine => ShowLine && (!CanCompress || ShowCompressed);

  public static readonly DependencyProperty CanCompressProperty = GetRegisterProperty("CanCompress", typeof(bool), true);

  public bool CanCompress
  {
    get => (bool) GetValue(CanCompressProperty);
    set => SetValue(CanCompressProperty, value);
  }

  // Same for other two properties

  public static DependencyProperty GetRegisterProperty(string name, Type type, object defaultValue)
  {
    return DependencyProperty.Register(name, type, typeof(Heading), new FrameworkPropertyMetadata(defaultValue));
  }
}

How I use the heading in another user control:

<local:Heading Text="{Binding HeaderText}"
                                   CanCompress="False" 
                                   ShowLine="False"/>

How can I proceed from here? I know ditching the calculated properties would be simple, but that would mean that I need to calculate their state somewhere else. I'd like to do that all in the Heading.

The main problem seems to be that the calculated properties don't get forced to refresh. Is that a fair summary?


Solution

  • You can implement this logic by defining a ControlTemplate and simple triggers:

    <UserControl x:Class="ExampleControl">
      <UserControl.Template>
        <ControlTemplate TargetType="ExampleControl">
    
          <StackPanel>
            <Border x:Name="TopLine"
                    Background="Black" 
                    Height="1"
                    Margin="0 10 0 0" />
            <Grid>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition />
              </Grid.ColumnDefinitions>
              <TextBlock Text="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Text, FallbackValue=Heading}"
                         Margin="0 10 10 0" />
              <Border x:Name="BottomLine"
                      Grid.Column="1"
                      Height="1"
                      Margin="0 10 0 0"
                      Background="Black"
                      VerticalAlignment="Center" />
            </Grid>
          </StackPanel>
    
          <ControlTemplate.Triggers>
            <Trigger Property="ShowLines" Value="False">
              <Setter TargetName="TopLine" Property="Visibility" Value="Collapsed" />
              <Setter TargetName="BottomLine" Property="Visibility" Value="Collapsed" />
            </Trigger>
    
            <MultiTrigger>
              <MultiTrigger.Conditions>
                <Condition Property="CanCompress" Value="True" />
                <Condition Property="ShowCompressed" Value="True" />
              </MultiTrigger.Conditions>
              <Setter TargetName="TopLine"
                      Property="Visibility"
                      Value="Collapsed" />
            </MultiTrigger>
    
            <MultiTrigger>
              <MultiTrigger.Conditions>
                <Condition Property="CanCompress" Value="True" />
                <Condition Property="ShowCompressed" Value="False" />
              </MultiTrigger.Conditions>
              <Setter TargetName="BottomLine"
                      Property="Visibility"
                      Value="Collapsed" />
            </MultiTrigger>
          </ControlTemplate.Triggers>
        </ControlTemplate>
      </UserControl.Template>
    </UserControl>
    

    Alternatively (and recommended) use a custom control (extend Control).