Background
I have an MVVM skeleton for a Risk game learning project. I have decided to attempt creating parts of the UI programmatically for both "convenience"/elegance and as a learning opportunity. The parts in question are custom Buttons that represent the game board's Territories and should, eventually, take in most of the user's input.
Pivotal Choice
A source of complication: My attempt to leverage a global Enum (TerrID_Enum.cs), which seemed like a logical move since the game Board is static. Each Territory receives an int ID number from 0 - 41. This then allows me to use arrays and for loops with the indices correlating directly to the appropriate Territory, in whichever frame (Model, VM, View). (As I said, a global Enum). I've seen different takes about this both here and elsewhere online, so feel free to chime in about it if you think there's something about this choice you think I need to know.
Custom Method for Building Binding Paths out of the For-Loop Index / Territory Enum relationship
As you will see, the programmatic initializer for the Buttons relies on a custom Method for writing the Binding Paths (Board.MakeStringPath()). It seems to work up to a point -- the Path.Data property inside of the custom Button's Content Grid gets the right Geometry at first -- but then after IntializeComponent() it appears to go to null? Beyond this, the ActualHeight and ActualWidth properties of both the bound Paths and the custom Buttons sit at 0 for some reason, and I don't know how this is related but it seems to be.
Most Relevant Code Snippets:
This is what my MainWindow.xaml and MainWindow.cs include. I've omitted a few Triggers and miscellaneous stuff for clarity:
<Window>
<Window.Resources>
<ControlTemplate x:Key="TerrButtonCT" TargetType="{x:Type Button}">
<Border Background="{TemplateBinding Background}" BorderBrush="{x:Null}" BorderThickness="0">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</ControlTemplate>
</Window.Resources>
</Window>
public partial class MainWindow : Window
{
public MainWindow()
{
Board VM = new(NewGame);
this.DataContext = VM;
Canvas mainCanvas = new Canvas();
this.AddChild(mainCanvas);
Button[] TerrButtons = new Button[42];
// Programatic creation and detail of the Territory Buttons
for (int index = 0; index < 42; index++)
{
// "Content" Grid and Shape, with associated Bindings, are created
Grid defaultGrid = new Grid();
Binding terrButtonPathData = new(VM.MakePathString(index, nameof(VM.Shapes)));
terrButtonPathData.Source = VM;
Binding terrButtonFill = new(VM.MakePathString(index, nameof(VM.Color)));
terrButtonFill.Source = VM;
Path terrPath = new Path() { Stretch = Stretch.Fill };
BindingOperations.SetBinding(terrPath, Path.DataProperty, terrButtonPathData);
BindingOperations.SetBinding(terrPath, Path.FillProperty, terrButtonFill);
defaultGrid.Children.Add(terrPath);
// Control Template brought in from Window Resources
ControlTemplate buttonCT = (ControlTemplate)this.Resources["TerrButtonCT"];
// Triggers
// Multi-Trigger for Mouse "Hover" behavior
MultiTrigger hoverTrigger = new MultiTrigger();
hoverTrigger.Conditions.Add(new() { Property = IsMouseOverProperty, Value = true} );
hoverTrigger.Conditions.Add(new() { Property = Button.IsPressedProperty, Value = false }); ;
// Setters for Trigger details. Shape and Fill need to be bound just like "Content"
hoverTrigger.Setters.Add(new Setter() { Property = BackgroundProperty, Value = null });
hoverTrigger.Setters.Add(new Setter() { Property = BorderBrushProperty, Value = null });
Path hoverPath = new() { Stretch = Stretch.Fill };
Binding hoverColor = new Binding(VM.MakePathString(index, nameof(VM.HoverColor)));
hoverColor.Source = VM;
BindingOperations.SetBinding(hoverPath, Path.DataProperty, terrButtonPathData);
BindingOperations.SetBinding(hoverPath, Path.FillProperty, hoverColor);
Grid hoverGrid = new Grid();
hoverGrid.Children.Add(hoverPath);
hoverTrigger.Setters.Add(new Setter() { Property = ContentProperty, Value = hoverGrid });
// Wrap everything into an individualized Style for the Button
Style buttonStyle = new Style();
buttonStyle.Triggers.Add(hoverTrigger);
buttonStyle.Setters.Add(new Setter() { Property = ContentProperty, Value = defaultGrid });
buttonStyle.Setters.Add(new Setter() { Property = TemplateProperty, Value = buttonCT });
// Feed custom details to the Button
TerrButtons[index] = new Button
{
Name = ((TerrID)index).ToString(),
Style = buttonStyle
};
// Add each Button to the MainWindow Child Canvas
mainCanvas.Children.Add((TerrButtons[index]));
}
InitializeComponent();
}
}
}
And here is the ViewModel so far:
internal class Board : ViewModelBase
{
private ObservableCollection<SolidColorBrush> _color;
private ObservableCollection<SolidColorBrush> _hoverColor;
public Board(Game newGame)
{
CurrentGame = newGame;
Shapes = new List<Geometry>();
Color = new ObservableCollection<SolidColorBrush>();
HoverColor = new ObservableCollection<SolidColorBrush>();
for (int index = 0; index < BaseArmies.Count; index++)
{
if (Application.Current.FindResource(((TerrID)index).ToString() + "Geometry") != null)
Shapes.Add(Application.Current.FindResource(((TerrID)index).ToString() + "Geometry") as Geometry);
Color.Add(Brushes.Black);
HoverColor.Add(Brushes.AntiqueWhite);
}
}
public List<Geometry> Shapes { get; init; }
public ObservableCollection<SolidColorBrush> Color
{
get { return _color; }
set
{
_color = value;
OnPropertyChanged(nameof(Color));
}
}
public ObservableCollection<SolidColorBrush> HoverColor
{
get { return _hoverColor; }
set
{
_hoverColor = value;
OnPropertyChanged(nameof(HoverColor));
}
}
...
internal string MakePathString(int index, string propertyName)
{
if (index >= 0 && index < 42 && propertyName != null)
{
string indexStr = index.ToString();
switch (propertyName)
{
case nameof(Color):
return "Color[" + indexStr + "]";
case nameof(HoverColor):
return "HoverColor[" + indexStr + "]";
case nameof(PressedColor):
return "PressedColor[" + indexStr + "]";
case nameof(DisabledColor):
return "DisabledColor[" + indexStr + "]";
case nameof(Shapes):
return "Shapes[" + indexStr + "]";
}
return "Property not found!";
}
else return "MakePathString() requires 0 < int index < 42 and string propertyName.";
}
Further Code / Notes
In addition to the global TerrID Enum, there is a massive App.xaml with Geometries for each of the Territories. I don't believe it's necessary to include here, but you can find it on Github.
The basic design for customizing a Button with a Path as its shape was adapted/lifted from here.
If need to see more: Full Project @ Github
Nuget Package included: MS MVVM Community Toolkit 8.2.0
Thanks!
I know this is a lot to go through, so thanks in advance!
Past Attempts and Frustration
I've tried setting a custom Button Style in XAML, but I want to create a custom Path string to enable the for-loop initializer and this would require either nested Bindings (A Binding on Binding.Path, which I discovered doesn't work because it's not a DependencyProperty), or some elaborate work with DataObjectProvider which I have yet to wrap my head around. (I also ditched a series of Converters as unworkable, but that might be a solution, I just don't see it).