Search code examples
xamldata-bindinggridavaloniauicolumndefinition

Avalonia: Can't bind to ColumnDefinition.Width


I have in my Avalonia user control a Grid whose column I want to hide when a bool property of my viewmodel (which derives from ReactiveObject) is set true by a viewmodel method (code at bottom). Since ColumnDefinition doesn't have the IsVisible property, I first attempted to use a style that targeted its Width. I know Avalonia doesn't do data triggers, so I tried:

      <Grid.Styles>
        <Style Selector="ColumnDefinition.Minimized">
          <Setter Property="Width" Value="0"/>
        </Style>
        <Style Selector="ColumnDefinition.Maximized">
          <Setter Property="Width" Value="400"/>
        </Style>
      </Grid.Styles>
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="32" />
        <ColumnDefinition Classes.Minimized="{Binding IsColumnContentMinimized}"
                          Classes.Maximized="{Binding !IsColumnContentMinimized}" />
        <ColumnDefinition Width="10" />
        <ColumnDefinition />
      </Grid.ColumnDefinitions>

But this gave me two build errors:

AVLN2000    Unable to resolve suitable regular or attached property (Minimized/Maximized) on type Avalonia.Base:Avalonia.Controls.Classes 

So I resigned to a converter:

        <ColumnDefinition Width="{Binding !IsColumnContentMinimized,
                            Converter={StaticResource BoolToDouble}, ConverterParameter=400}" />

From App.axaml, inside the Application.Resources tag:

    <myNamespaceAlias:BoolToDoubleConverter x:Key="BoolToDouble" />
    
    namespace MyProject.myNamespace;

    public class BoolToDoubleConverter : IValueConverter {
      public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
        if (value is bool && parameter != null) {
          var param = (double.NegativeZero);

          if (double.TryParse(parameter.ToString(), out param) && (bool)value) {
            return (double)param;
          }
        }

        return 0;
      }

      public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) {
        throw new NotSupportedException();
      }
    }

It actually returned the correct values, but the column rendered more than 800 pixels wide and would never resize when IsColumnContentMinimized flipped values (other things bound to IsColumnContentMinimized perform as expected). So then I tried binding the width directly to a companion property set in my VM method:

        <ColumnDefinition Width="{Binding ContentColumnWidth}" />
  protected bool _isColumnContentMinimized;
  protected double _contentColumnWidth;
...
  public bool IsColumnContentMinimized {
    get => _isColumnContentMinimized;
    set => this.RaiseAndSetIfChanged(ref _isColumnContentMinimized, value);
  }
...
  public double ContentColumnWidth {
    get => _contentColumnWidth;
    set => this.RaiseAndSetIfChanged(ref _contentColumnWidth, value);
  }
...
  private void ExecuteToggleMinimizeColumnContent() {
    IsColumnContentMinimized = !IsColumnContentMinimized;
    ContentColumnWidth = (IsColumnContentMinimized ? 0 : 400d);
  }

But no effect! Please tell me how I can get this to work without using code-behind (assuming that would work), thanks...


Solution

  • So, @ibram's answer was useful, although I did tweak it slightly by making the column content show and hide with the property, allowing me to omit the Grid.Styles tag:

          <TabControl x:Name="tclContent" Grid.Column="1"
                      IsVisible="{Binding !IsColumnContentMinimized}">
            (stuff to hide sometimes)
          </TabControl>
    

    And it did work at first; toggling IsColumnContentMinimized effectively hid the column, but now I should divulge the full problem scenario.

    You see, the content I want to hide sits to the left of a GridSplitter column that determines the visible width of the content column, and if I move this separator, the toggle still hides the TabControl content, but no longer resizes the column.

    And as it turns out, you CAN bind to a Grid column's width, it just has to be the right data type: GridLength. Below is a new converter, its resource definition, and the employing AXAML (abridged to the important stuff):

    public class BoolToGridColumnWidthConverter : IValueConverter {
      public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) {
        if (value is bool && parameter != null) {
          var param = (double.NegativeZero);
    
          if (double.TryParse(parameter.ToString(), out param) && (bool)value) {
            return new GridLength((double)param);
          }
        }
    
        return new GridLength(0);
      }
      ...
    
        <myNamespaceAlias:BoolToGridColumnWidthConverter x:Key="BoolToWidth" />
    
        <Grid HorizontalAlignment="Stretch">
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="32" />
            <ColumnDefinition Width="{Binding !IsColumnContentMinimized,
                              Converter={StaticResource BoolToWidth},
                              ConverterParameter=400}" />
            <ColumnDefinition Width="10" />
            <ColumnDefinition />
          </Grid.ColumnDefinitions>
          <StackPanel x:Name="stpToolbarAtLeft">
            <Button IsVisible="{Binding !IsColumnContentMinimized}" Content="Minimize"
                    Command="{Binding ToggleMinimizeColumnContentCommand}" />
            <Button IsVisible="{Binding IsColumnContentMinimized}" Content="Maximize"
                    Command="{Binding ToggleMinimizeColumnContentCommand}" />
            (other buttons and stuff)
          </StackPanel>
          <TabControl x:Name="tclContent" Grid.Column="1">
            (stuff to hide sometimes)
          </TabControl>
          <GridSplitter x:Name="gspResizesContentAndLastColumns" Grid.Column="2"
                        Background="Colors.DarkRed" ResizeDirection="Columns"
                        HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
                        ResizeBehavior="PreviousAndNext" />
          <DockPanel x:Name="dkpRestOfStuff" Grid.Column="3"
                     HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
            (stuff to fill the rest of the space)
          </DockPanel>
        </Grid>
    

    This let me grab the splitter and resize the column anytime, and the content filled its space when visible. Only thing was, when I toggled to maximize again, it always came back to 400 width. While not necessarily bad, it may be ideal to have it return to the width prior to hiding. So I added a proper GridLength property to the VM, tied its fate to the visibility flag, and bound it to the column def:

            <ColumnDefinition Width="{Binding ContentColumnWidth, Mode=TwoWay}" />
    
      protected bool _isColumnContentMinimized;
      protected GridLength _lastContentColumnWidth;
      protected GridLength _contentColumnWidth;
      public ICommand ToggleMinimizeColumnContentCommand { get; set; } 
      ...
      public bool IsColumnContentMinimized {
        get => _isColumnContentMinimized;
        set => this.RaiseAndSetIfChanged(ref _isColumnContentMinimized, value);
      }
    
      public GridLength ContentColumnWidth {
        get => _contentColumnWidth;
        set {
          this.RaiseAndSetIfChanged(ref _contentColumnWidth, value);
          IsColumnContentMinimized = (value.Value == 0);
        }
      }
    
      public MyVM() {
      ...
        ToggleMinimizeColumnContentCommand = ReactiveCommand.Create(
          ExecuteToggleMinimizeColumnContent);
        ContentColumnWidth = new GridLength(400d);
      }
    
      private void ExecuteToggleMinimizeColumnContent() {
        IsColumnContentMinimized = !IsColumnContentMinimized;
    
        if (IsColumnContentMinimized) {
          _lastContentColumnWidth = ContentColumnWidth;
          ContentColumnWidth = new GridLength(0);
        } else {
          if (_lastContentColumnWidth.Value == 0) {
            // Default just in case never minimized via button
            ContentColumnWidth = new GridLength(400d);
          } else {
            ContentColumnWidth = _lastContentColumnWidth;
          }
        }
      }
    
    

    Now if I click the "Minimize" button, the next click of the "Maximize" button resizes the content column to where it most recently was. I can also drag the splitter in or out of its leftmost position and have the buttons show accordingly. Only remaining possible annoyance is, if I drag to 0 width and click "Maximize", the splitter goes to the last pre-minimize position, which may not be the position from which I dragged it. That's because only minimizing saves a value to _lastContentColumnWidth, and I don't want to save a value in every set of ContentColumnWidth, since such a set happens at every pixel along the way, and I could end up with `_lastContentColumnWidth' being 1 or 0. The way I see it, I have two options:

    1. Do such an assignment anyway, and in the case of a _lastContentColumnWidth of 0 have a maximize restore the width to a default value like 400, as done in ExecuteToggleMinimizeColumnContent above.
    2. Use some timing mechanism or drag detection that only sets _lastContentColumnWidth when movement is stopped at a non-zero position/width.

    For now, what's implemented above should be good enough for my users!