Reputation: 61512
I am trying to build a UI in WPF to a specification. The UI is for editing a collection of items. Each item has an editable string property, and also a variable number of read-only strings which the UI needs to display. It might look something like this:
or, depending on data, might have a different number of text label columns:
The number of text columns is completely variable and can vary from one to "lots". The specification calls for the columns to be sized to fit the longest entry (they are invariably very short), and the whole thing should look like a grid. This grid will be contained in a window, stretching the text box horizontally to fit the window.
Importantly, the text boxes can contain multi-line text and will grow automatically to fit the text. The rows below need to be pushed out of the way if that happens.
Question: what would be a good way of doing this in WPF?
Coming from a WinForms background, I am thinking of a TableLayoutPanel
, which gets populated directly by code I write. However, I need to do this in WPF. While I could still just get myself a Grid
and populate it in code, I would really rather prefer a way that's more in line with how things are done in WPF: namely, define a ViewModel, populate it, and then describe the View entirely in XAML. However, I can't think of a way of describing such a view in XAML.
The closest I can get to this using MVVM and XAML is to use an ItemsControl
with one item per row, and use a data template which, in turn, uses another ItemsControl
(stacked horizontally this time) for the variable number of labels, followed by the text box. Unfortunately, this can't be made to align vertically in a grid pattern like the spec requires.
Upvotes: 2
Views: 2756
Reputation: 644
You're probably way past this issue, but I had a similar issue recently and I got it to work surprisingly well in xaml, so I thought I'd share my solution.
The major downside is that you have to be willing to put an upper-bound on what "lots" of labels means. If lots can mean 100s, this won't work. If lots will definitely be less than the number of times you're willing to type Ctrl+V, you might be able to get this to work. You also have to be willing to put all the labels into a single ObservableCollection property in your view model. It sounded to me in your question that you already tried that out anyway though.
I takes advantage of AlternationIndex
to get the index of the label and assign it to a column. Think I learned that from here. If an item has < x labels the extra columns won't get in the way. If an item has > x labels, the labels will start stacking on top of each other.
<!-- Increase AlternationCount and RowDefinitions if this template breaks -->
<ItemsControl ItemsSource="{Binding Labels}" IsTabStop="False" AlternationCount="5">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemContainerStyle>
<Style TargetType="{x:Type ContentPresenter}">
<Setter Property="Grid.Column"
Value="{Binding RelativeSource={RelativeSource Self},
Path=(ItemsControl.AlternationIndex)}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid IsItemsHost="True">
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="A"/>
<ColumnDefinition SharedSizeGroup="B"/>
<ColumnDefinition SharedSizeGroup="C"/>
<ColumnDefinition SharedSizeGroup="D"/>
<ColumnDefinition SharedSizeGroup="E"/>
</Grid.ColumnDefinitions>
</Grid>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
Upvotes: 0
Reputation: 26298
Doing it in the code-behind is really not a WPFish(wpf way). Here I offer you my solution, which looks nice imo.
0) Before starting, you need GridHelpers. Those make sure you can have dynamically changing rows/columns. You can find it with a little bit of google:
How can I dynamically add a RowDefinition to a Grid in an ItemsPanelTemplate?
Before actually implementing something, you need to restructure your program a little. You need new structure "CustomCollection", which will have:
1) Start by creating an ItemsControl that holds Grid. Make sure Grid RowDefinitions/ColumnDefinitions are dynamic. Apply ItemContainerStyle.
<ItemsControl
ItemsSource="{Binding Collection.ActualItems,
Converter={StaticResource presentationConverter}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Grid
local:GridHelpers.RowCount="{Binding Collection.RowCount}"
local:GridHelpers.StarColumns="{Binding Collection.ColumnCount,
Converter={StaticResource subtractOneConverter}"
local:GridHelpers.ColumnCount="{Binding Collection.ColumnCount}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="{x:Type FrameworkElement}">
<Setter Property="Grid.Row" Value="{Binding RowIndex}"/>
<Setter Property="Grid.Column" Value="{Binding ColumnIndex}"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
The only thing left to do: implement presentationConverter which converts your Viewmodel presentation to View presentation. (Read: http://wpftutorial.net/ValueConverters.html)
The converter should give back a collection of items where each "label" or "textbox" is a seperate entity. Each entity should have RowIndex and ColumnIndex.
Here is entity class:
public class SingleEntity
{
..RowIndex property..
..ColumnIndex property..
..ContentProperty.. <-- This will either hold label string or TextBox binded property.
..ContentType..
}
Note that ContentType is an enum which you will bind against in ItemsTemplate to decide if you should create TextBox or Label.
This might seem like a quite lengthy solution, but it actually is nice for few reasons:
If you have any questions, let me know.
Upvotes: 1
Reputation: 164
You've asked for quite a bit, the following code shows how to build a grid with the controls you want that sizes as needed, along with setting up the bindings:
public void BuildListTemplate(IEnumerable<Class1> myData, int numLabelCols)
{
var myGrid = new Grid();
for (int i = 0; i < myData.Count(); i++)
{
myGrid.RowDefinitions.Add(new RowDefinition() { Height= new GridLength(0, GridUnitType.Auto)});
}
for (int i = 0; i < numLabelCols; i++)
{
myGrid.ColumnDefinitions.Add(new ColumnDefinition() { Width = new GridLength(0, GridUnitType.Auto) });
}
myGrid.ColumnDefinitions.Add(new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) });
for (int i = 0; i < myData.Count(); i++)
{
for (int j = 0; j < numLabelCols; j++)
{
var tb = new TextBlock();
tb.SetBinding(TextBlock.TextProperty, new Binding("[" + i + "].labels[" + j + "]"));
tb.SetValue(Grid.RowProperty, i);
tb.SetValue(Grid.ColumnProperty, j);
tb.Margin = new Thickness(0, 0, 20, 0);
myGrid.Children.Add(tb);
}
var edit = new TextBox();
edit.SetBinding(TextBox.TextProperty, new Binding("[" + i + "].MyEditString"));
edit.SetValue(Grid.RowProperty, i);
edit.SetValue(Grid.ColumnProperty, numLabelCols);
edit.AcceptsReturn = true;
edit.TextWrapping = TextWrapping.Wrap;
edit.Margin = new Thickness(0, 0, 20, 6);
myGrid.Children.Add(edit);
}
contentPresenter1.Content = myGrid;
}
A Quick Explanation of the above All it is doing is creating the grid, defines rows for the grid; and a series of columns for the grid that auto size for the content.
Then it simply generates controls for each data point, sets the binding path, and assigns various other display attributes along with setting the correct row/column for the control.
Finally it puts the grid in a contentPresenter that has been defined in the window xaml in order to show it.
Now all you need do is create a class with the following properties and set the data context of the contentPresenter1 to a list of that object:
public class Class1
{
public string[] labels { get; set; }
public string MyEditString { get; set; }
}
just for completeness here is the window xaml and constructor to show hooking it all up:
<Window x:Class="WpfApplication1.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525">
<ContentPresenter Name="contentPresenter1"></ContentPresenter>
</Window>
public MainWindow()
{
InitializeComponent();
var data = new List<Class1>();
data.Add(new Class1() { labels = new string[] {"the first", "the second", "the third"}, MyEditString = "starting text"});
data.Add(new Class1() { labels = new string[] { "col a", "col b" }, MyEditString = "<Nothing>" });
BuildListTemplate(data, 3);
DataContext = data;
}
You can of course use other methods such as a listview and build a gridview for it (I'd do this if you have large numbers of rows), or some other such control, but given your specific layout requirements probably you are going to want this method with a grid.
EDIT: Just spotted that you're looking for a way of doing in xaml - tbh all I can say is that I don't think that with the features you're wanting that it is too viable. If you didn't need to keep things aligned to dynamically sized content on seperate rows it would be more viable... But I will also say, don't fear code behind, it has it's place when creating the ui.
Upvotes: 1
Reputation: 12550
You can create your own Panel
and then decide on how you want the layout logic to work for the children that are put inside it.
Look at this for inspiration:
You could have a "ColumnCount" property, and then use that within the MeassureOverride
and ArrangeOverride
to decide when to wrap a child.
Or you could modify this bit of code (I know it's Silverlight code, but it should be close to the same in WPF).
Instead of having the same width for all columns (the default is 1-star "*"), you could add a List/Collection property that records the different column widths sized you want, then in the AutoGrid_LayoutUpdated
use those widths to make the ColumnDefinition
values.
Upvotes: 1
Reputation: 35594
Well, the simple yet not not very advanced way would be to fill the UI dynamically in the code-behind. This seems to be the easiest solution, and it more or less matches your winforms experience.
If you want to do it in a MVVM way, you should perhaps use ItemsControl
, set the collection of items as its ItemsSource
, and define a DataTemplate
for your collection item type.
I would have the DataTemplate
with something like that:
<Window x:Class="SharedSG.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:app="clr-namespace:SharedSG"
Title="MainWindow" Height="350" Width="525">
<Window.Resources>
<DataTemplate DataType="{x:Type app:LabelVM}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition SharedSizeGroup="G1"/>
<ColumnDefinition SharedSizeGroup="G2"/>
<ColumnDefinition MinWidth="40" Width="*"/>
</Grid.ColumnDefinitions>
<Label Content="{Binding L1}" Grid.Column="0"/>
<Label Content="{Binding L2}" Grid.Column="1"/>
<TextBox Grid.Column="2"/>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Grid.IsSharedSizeScope="True">
<ItemsControl ItemsSource="{Binding}"/>
</Grid>
</Window>
Upvotes: 0
Reputation: 185290
This does not map all too well, you could probably use a DataGrid
and retemplate it to look like this. In other approaches you may need to imperatively add columns or the like to get the layout done right.
(You can hook into AutoGeneratingColumn
to set the width of that one writeable column to *
)
Upvotes: 1