Anadovník
Anadovník

Reputation: 11

How to put elements on Canvas and bind their position to the element's viewModel in Avalonia

I am trying to create an editor, where you can put predefined shapes onto the canvas and then interact with them. I understand how to put my shape in a specific place on canvas using Canvas.SetTop() method. Now I don't know how to change the shape's position after it has been rendered. I am trying to stick with MVVM rules. So far I have created this:

MainWindow (menu is not important, but posting it just in case):

<DockPanel LastChildFill="True">
        <Menu DockPanel.Dock="Top">
            <MenuItem Header="File">
                <MenuItem Header="Exit" />
            </MenuItem>
            <MenuItem Header="Action">
                <MenuItem Header="Draw place" />
                <MenuItem Header="Draw transition" />
                <MenuItem Header="Draw arc" />
                <MenuItem Header="Play" />
            </MenuItem>
        </Menu>

        <Canvas Name="MyCanvas" PointerReleased="Canvas_OnPointerReleased" Background="#141414" Margin="10" />
    </DockPanel>

MainWindow code behind:

private void Canvas_OnPointerReleased(object? sender, PointerReleasedEventArgs e)
    {
        var viewModel = DataContext as MainWindowViewModel;
        var clickPosition = e.GetPosition(MyCanvas);
        viewModel?.CreateTransitionCommand.Execute(clickPosition);
    }

MainWindowViewModel:

public partial class MainWindowViewModel : ObservableObject
{
    private ObservableCollection<NodeViewModelBase> Elements { get; } = new ObservableCollection<NodeViewModelBase>();
    
    [RelayCommand]
    private void CreateTransition(Point position)
    {
        var model = new Transition();
        var viewModel = new TransitionViewModel(model) {X = position.X, Y = position.Y};
        Elements.Add(viewModel);
    }
}

Model Transition I guess is not important here, but i am gonna show TransitionViewModel and also TransitionView.

TransitionViewModel:

public partial class TransitionViewModel : NodeViewModelBase
{
    public TransitionViewModel(Transition transition)
    {
        Transition = transition;
    }

    private Transition Transition { get; set; }
    
    [ObservableProperty]
    private double _x;
    
    [ObservableProperty]
    private double _y;

    [ObservableProperty]
    private string _borderBrush = "#444444";

    [ObservableProperty] 
    private int _size = 40;

    [ObservableProperty]
    private string _name = "Transition";

    [ObservableProperty] 
    private int _fontSize = 10;
}

TransitionView:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:vm="using:Editor.ViewModels"
             mc:Ignorable="d"
             x:Class="Editor.Views.TransitionView"
             x:DataType="vm:TransitionViewModel">

    <StackPanel>
        <Border BorderBrush="{Binding BorderBrush}" BorderThickness="1" HorizontalAlignment="Center">
            <Rectangle Fill="#141414" Height="{Binding Size}" Width="{Binding Size}" />
        </Border>
        <TextBlock Text="{Binding Name}" FontSize="{Binding FontSize}" HorizontalAlignment="Center" Margin="0, 3, 0, 0" />
    </StackPanel>
</UserControl>

The problem now is, how to efficiently and simply add this TransitionView to Canvas. Of course I tried to skip the command I execute in MainWindow code behind and instead, just create a view, set its data context, and add it to canvas. But I didn't find any way, how to change its location on canvas after I leave the event handler method.

So I tried to find a solution. At first, I tried to change my command, so all it does is add an element to the collection. Everything else was handled by code behind to make it more simple. I tried to set properties and bind them in my TransitionView like this but it didn't work:

<UserControl.Styles>
        <Style Selector="UserControl">
            <Setter Property="Canvas.Left" Value="{Binding X}"/>
            <Setter Property="Canvas.Top" Value="{Binding Y}"/>
        </Style>
    </UserControl.Styles>

Afterward, I found somewhere on the internet, that for some people ItemsControl works. I tried multiple approaches to this. All I got was just written on the screen "Avalonia.Markup.Xaml.Templates.ItemsPanelTemplate". This for sure says it's wrongly implemented, but I am not able to fix it. As I said I tried multiple approaches, here is my last one:

<Window.DataTemplates>
        <DataTemplate DataType="vm:TransitionViewModel">
            <views:TransitionView />
        </DataTemplate>
    </Window.DataTemplates>


<Canvas Name="MyCanvas" PointerReleased="Canvas_OnPointerReleased" Background="#141414" Margin="10">
            <ItemsControl ItemsSource="{Binding Elements}">
                <ItemsPanelTemplate>
                    <Canvas />
                </ItemsPanelTemplate>

                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <ContentControl Content="{Binding}" />
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </Canvas>

The reason I tried to use ContentControl is that I will need to not draw in canvas just rectangles(TransitionView), but also circles. So I tried to create a system, where it can recognize how should each element in ItemsSource be drawn on canvas.


I think it's good to mention that I was indeed able to find some sort of solution, but it's not ideal and also it does not work as it should. I tried to add PointerRelease listener in TransitionView:

<StackPanel PointerReleased="InputElement_OnPointerReleased">
        <Border BorderBrush="{Binding BorderBrush}" BorderThickness="1" HorizontalAlignment="Center">
            <Rectangle Fill="#141414" Height="{Binding Size}" Width="{Binding Size}" />
        </Border>
        <TextBlock Text="{Binding Name}" FontSize="{Binding FontSize}" HorizontalAlignment="Center" Margin="0, 3, 0, 0" />
    </StackPanel>

After that in code behind I just used Canvas.setTop(this, 100) and Canvas.setLeft(this, 100) to find out if it works. To my surprise, it worked and it moved the rectangle on canvas to point 100,100. But my mouse click was recognized not only by StackPanel in TransitionView, but also by canvas itself. So I moved my view but also on the same spot where it was originally, a new one was created. I wasn't able to find a proper solution for this issue, but it might be a way to go.


My approach might be totally wrong and I am open to any suggestions. I am new to Avalonia and basically to any UI framework that uses MVVM principles and its view is written in XAML. I am looking for the most elegant, clear, and principal-friendly solution.

Upvotes: 0

Views: 702

Answers (1)

B0lver
B0lver

Reputation: 41

Try explore this repo. Also, check this example of a movable control.

As for my experience, I used second example, but instead of a simple rectangle it was "DisplayControl" - an element, that create its content based on binded view model. On a main view I have a collection view, and DisplayControl is set as template. And in my main view model I have a list of Elements_ViewModels. When I want to add a window, I can just add new view model in a list - collection will be updated automatically and new window will be drawn.

EDIT: This is what I mean.

 <Grid>...<Grid/>
 <ItemsControl ItemsSource="{Binding MovablePanels}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <border:MovableBorder>
                <border:PropertyPanelControl Content="{Binding}" />
            </border:MovableBorder>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

In my page I have an element, that contains a list of movable panels and displays it using the following template.

MovableBorder is just an empty Border control, that handles movement:

var currentPosition = e.GetPosition((Visual?)Parent);

var offsetX = currentPosition.X - _positionInBlock.X;
var offsetY = currentPosition.Y - _positionInBlock.Y;

_transform = new TranslateTransform(offsetX, offsetY);

LimitTransform();
RenderTransform = _transform;

As for the PropertyPanelControl, it is a UserControl, that contains a ContentPresenter:

<UserControl.DataTemplates>
    <controls:SettingsDataTemplate />
</UserControl.DataTemplates>
<ContentPresenter
    x:Name="ref_ContentPresenter"
    Content="{Binding}"
    DataContext="{Binding}" />

SettingsDataTemplate handles binding and tries to display view, associated with binded ViewModel:

public class SettingsDataTemplate : IDataTemplate
{
    public Control? Build(object? param)
    {
        var t = param.GetType();
        var itype = typeof(IViewFor<>).MakeGenericType(t);
        var view = Locator.Current.GetService(itype);
        return (Control?)view;
    }

    public bool Match(object? data)
    {
        return data is SettingsViewModelBase || data is IMovableViewModel || data is ViewModelBase;            
    }
}

Hope it helps!

Upvotes: 0

Related Questions