6
votes

For the last two days I have been stuck on one (simple?) problem. I searched a lot on the internet, but I cannot find an example that solves the situation I have completely (everytime one aspect is left out, which is the breaking factor in my own implementation).

What do I want:

Create my own WPF control, that displays an image with on top rectangles (or actually shapes in general) that stay fixed while zooming and panning. Further, those rectangles need to be resized (todo yet) and be moveable (doing right now).

I want this control to adhere to the MVVM design pattern.

What do I have:

I have a XAML file with a ItemsControl. This to show a dynamic amount of rectangles (that come from my ViewModel). It is bound to the RectItems of my ViewModel (an ObservableCollection). I want to render the items as Rectangles. These rectangles must be moveable by a user using his mouse. Upon moving it should update my model entities in the ViewModel.

XAML:

<ItemsControl ItemsSource="{Binding RectItems}">
<ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Canvas IsItemsHost="True">

            </Canvas>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
    <Style>
        <Setter Property="Canvas.Left" Value="{Binding TopLeftX}"/>
        <Setter Property="Canvas.Top" Value="{Binding TopLeftY}"/>
    </Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
    <DataTemplate>
        <Rectangle Stroke="Black" StrokeThickness="2" Fill="Blue" Canvas.Left="0" Canvas.Top="0"
           Height="{Binding Height}" Width="{Binding Width}">
            <i:Interaction.Triggers>
                <i:EventTrigger EventName="MouseLeftButtonUp">
                    <i:InvokeCommandAction Command="{Binding ElementName=border,Path=DataContext.MouseLeftButtonUpCommand}" CommandParameter="{Binding}" />
                </i:EventTrigger>
                <i:EventTrigger EventName="MouseLeftButtonDown">
                    <i:InvokeCommandAction Command="{Binding ElementName=border,Path=DataContext.MouseLeftButtonDownCommand}" CommandParameter="{Binding}" />
                </i:EventTrigger>
                <i:EventTrigger EventName="MouseMove">
                    <i:InvokeCommandAction Command="{Binding ElementName=border,Path=DataContext.MouseMoveCommand}" CommandParameter="{Binding}" />
                </i:EventTrigger>
            </i:Interaction.Triggers>
        </Rectangle>
    </DataTemplate>
</ItemsControl.ItemTemplate>

ViewModel:

public class PRDisplayViewModel : INotifyPropertyChanged
{
    private PRModel _prModel;

    private ObservableCollection<ROI> _ROIItems = new ObservableCollection<ROI>(); 

    public PRDisplayViewModel()
    {
        _prModel = new PRModel();

        ROI a = new ROI();
        a.Height = 100;
        a.Width = 50;
        a.TopLeftX = 50;
        a.TopLeftY = 150;

        ROI b = new ROI();
        b.Height = 200;
        b.Width = 200;
        b.TopLeftY = 200;
        b.TopLeftX = 200;

        _ROIItems.Add(a);
        _ROIItems.Add(b);

        _mouseLeftButtonUpCommand = new RelayCommand<object>(MouseLeftButtonUpInner);
        _mouseLeftButtonDownCommand = new RelayCommand<object>(MouseLeftButtonDownInner);
        _mouseMoveCommand = new RelayCommand<object>(MouseMoveInner);
    }

    public ObservableCollection<ROI> RectItems
    {
        get { return _ROIItems; }
        set { }
    }

    private bool isShapeDragInProgress = false;
    double originalLeft = Double.NaN;
    double originalTop = Double.NaN;
    Point originalMousePos;

    private ICommand _mouseLeftButtonUpCommand;

    public ICommand MouseLeftButtonUpCommand
    {
        get { return _mouseLeftButtonUpCommand; }
        set { _mouseLeftButtonUpCommand = value; }
    }

    public void MouseLeftButtonUpInner(object obj)
    {
        Console.WriteLine("MouseLeftButtonUp");

        isShapeDragInProgress = false;

        if (obj is ROI)
        {
            var shape = (ROI)obj;
            //shape.ReleaseMouseCapture();
        }
    }


    /**** More Commands for MouseLeftButtonDown and MouseMove ****/

Class ROI (this would reside inside PRModel later):

public class ROI
{
    public double Height { get; set; }

    public double TopLeftX { get; set; }

    public double TopLeftY { get; set; }

    public double Width { get; set; }
}

So the way I see it is the following:

ItemsControl renders an ROI object into a Rectangle. Mouse events on the Rectangle are handled by Commands in the ViewModel. The ViewModel, upon receiving a mouse event, handles updates directly on the ROI object. Then, the view should redraw (assuming the ROI object has changed), and therefore generate new Rectangles, etc.

What is the problem?

In the Mouse event handlers, I need to call the CaptureMouse() method of the Rectangle on which the mouse event occurred. How do I get access to this Rectangle?

Most probably the actual problem is that my perspective on MVVM here is wrong. Should I actually try to update the ROI objects in the mouse event handlers in the ViewModel? Or should I only update the Rectangle objects? If the latter, then how do updates propagate to the actual ROI objects?

I checked many other questions, among which the ones below, but I still did not manage to solve my problem:

Add n rectangles to canvas with MVVM in WPF

Dragable objects in WPF in an ItemsControl?


EDIT: Thank you all for your replies. Your input helped a lot. Unfortunately, I am still not able to get this working (I am new to WPF, but I cannot imagine this to be so hard).

Two new attempts, for which I both implemented the INotifyPropertyChanged interface in the ROI class.

Attempt 1: I implemented the dragging with MouseDragElementBehavior like this:

                <ItemsControl ItemsSource="{Binding RectItems}">
                <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <Canvas IsItemsHost="True">
                            </Canvas>
                        </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemContainerStyle>
                    <Style TargetType="ContentPresenter">
                        <!-- //-->
                        <Setter Property="Canvas.Left" Value="{Binding TopLeftX, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
                        <Setter Property="Canvas.Top" Value="{Binding TopLeftY, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
                    </Style>
                </ItemsControl.ItemContainerStyle>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid>
                        <Rectangle Stroke="Black" StrokeThickness="2" Canvas.Left="0" Canvas.Top="0"
                           Height="{Binding Height}" Width="{Binding Width}">
                                <i:Interaction.Behaviors>
                                    <ei:MouseDragElementBehavior/>
                                </i:Interaction.Behaviors>
                            </Rectangle>
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

That worked perfect! Everything is for free, even when I zoom in on a border (which is a parent element to all of this).

problem here: After dragging a rectangle, I see this in the UI, but my ROI object (linked to this rectangle) is not updated? I specified TwoWay binding with the PropertyChanged UpdateSourceTrigger, but still that does not work.

question here: How do I get, in this situation, my ROI objects updated? I can implement the DragFinished event of MouseDragElementBehavior, but here I do not get the corresponding ROI object? @Chris W., where could I bind a UIElement.Drop handler? Could I get the corresponding ROI object there?

Attempt 2: Same as attempt 1, but now I also implemented the EventTriggers (like in my original post). There, I did not do anything to update the rectangle in the UI, but I only updated the corresponding ROI object.

problem here: This did not work, because the Rectangles we moved 'twice' the vector they should. Probably the first movement is from the dragging itself, and the second movement is the manual update (from me) in the corresponding ROIs.

Attempt 3: Instead of using MouseDragElementBehavior, "simply" implement the dragging myself using the EventTriggers (like in my original post). I did not update any Rectangle (UI), but only the ROIs (shifted their TopLeftX and TopLeftY).

problem here: This actually worked, except in the case of zooming. Further, the dragging was not nice, because it was 'flickering' a bit, and while moving the mouse too fast, it would loose its rectangle. Clearly, in the MouseDragElementBehavior, more logic has been put to make this smooth.

@Mark Feldman: thank you for your answer. It did not yet solve my problem, but I like the idea of not using GUI classes in my ViewModel (like Mouse.GetPosition to implement draggin myself). To decouple using a converter I will implement later, if the functionality is working.

@Will: you are right (MVVM != no code behind). Unfortunately I do not see right now how I can exploit this, as the event handlers from MouseDragElementBehavior do not know about a ROI (and this I would need to update the ViewModel).


EDIT 2:

Something that is working (at least it seems so) is the following (using MouseDragElementBehavior):

<ItemsControl ItemsSource="{Binding RectItems}">
                <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <Canvas IsItemsHost="True">
                            </Canvas>
                        </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemContainerStyle>
                    <Style TargetType="ContentPresenter">

                    </Style>
                </ItemsControl.ItemContainerStyle>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid>
                        <Rectangle Stroke="Black" StrokeThickness="2" Canvas.Left="0" Canvas.Top="0"
                           Height="{Binding Height}" Width="{Binding Width}">
                                <i:Interaction.Behaviors>
                                    <ei:MouseDragElementBehavior X="{Binding TopLeftX, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Y="{Binding TopLeftY, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">

                                    </ei:MouseDragElementBehavior>
                                </i:Interaction.Behaviors>
                            </Rectangle>
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

I bound the X and Y property of MouseDragElementBehavior to the appropriate property of the ViewModel. When I drag the rectangle around, I see the values in the corresponding ROI updated! Further, there is no 'flickering' or other problems while dragging.

the problem still: I had to remove the code in ItemContainerStyle, used to initialize the positions of the rectangles. Probably an update in the binding of MouseDragElementBehavior causes an update here as well. This is visible while dragging (the rectangle jumps quickly between two positions).

question: using this approach, how can I initialize the position of the rectangles? Further, this feels like an hack, so is there a more appropriate approach?


EDIT 3:

The following code will also initialize the rectangles on the appropriate position:

<ItemsControl ItemsSource="{Binding RectItems}">
                <ItemsControl.ItemsPanel>
                        <ItemsPanelTemplate>
                            <Canvas IsItemsHost="True">
                            </Canvas>
                        </ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemContainerStyle>
                    <Style TargetType="ContentPresenter">
                        <!-- //-->
                        <Setter Property="Canvas.Left" Value="{Binding TopLeftX, Mode=OneTime}"/>
                        <Setter Property="Canvas.Top" Value="{Binding TopLeftY, Mode=OneTime}"/>
                    </Style>
                </ItemsControl.ItemContainerStyle>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Grid>
                        <Rectangle Stroke="Black" StrokeThickness="2" Canvas.Left="0" Canvas.Top="0"
                           Height="{Binding Height}" Width="{Binding Width}">
                                <i:Interaction.Behaviors>
                                    <ei:MouseDragElementBehavior X="{Binding TopLeftX, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Y="{Binding TopLeftY, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">

                                    </ei:MouseDragElementBehavior>
                                </i:Interaction.Behaviors>
                            </Rectangle>
                        </Grid>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>

Problem still: This feels 'hacky'. Is there a 'better' way of doing this (for so far it seems to work)?


EDIT 4: I ended up using Thumbs. Using DataTemplates I was able to define the appearance of an item from my ViewModel (so to use e.g. Thumbs) and via ControlTemplates I was able to define how the thumbs actually should look like (an rectangle, for example). Advantage of Thumbs is that drag&drop is already implemented!

2
Before you get too far down this rabbit hole. Have you looked at MouseDragElementBehavior and using the UIElement.Drop event?Chris W.
Found this link from Chris point. may help.Abin
MVVM != no codebehind. Your UI should control what goes on in the UI, including moving elements. Best way to do an overlay like this would be to create a user control with a Canvas in it that takes care of displaying and moving the rectangles.user1228
Are you able to provide complete code (including the behaviors you use) to demonstrate your hard earned solution?MoonKnight

2 Answers

0
votes

Well your first problem is that ROI doesn't support INotifyPropertyChange, so changing them won't update your graphics. That needs to be fixed first.

With repect to the event issue take a look at my Perfy project. More specifically, look at the MouseCaptureBehavior class which is responsible for intercepting mouse messages and packaging them up for consumption by the view model including the provision of capture and release functionality. In my more complex applications I create a contract between the view and view model which typically looks something like this:

public interface IMouseArgs
{
    Point Pos { get; }
    Point ParentPos { get; }
    void Capture(bool parent = false);
    void Release(bool parent = false);
    bool Handled { get; set; }
    object Data { get;}
    bool LeftButton { get; }
    bool RightButton { get; }
    bool Shift { get; }
    void DoDragDrop(DragDropEffects allowedEffects);
}

There are a few things to note about this interface. First, the view model doesn't care what the implemention is, that's left entirely up to the view. Second, there are Capture/Release handlers for the view model to call, again provided by the view. Finally there's a Data field, which I typically set to contain the DataContext of whichever object was clicked, useful for passing back to the view to let it know which object(s) you're talking about.

That covers things from the view model side, now we need to implement this view side. I usually do that with an event trigger that binds directly to the command handlers in the view model but uses a converter to take the view-specific event args and turn them into something that supports the IMouseArgs object that my view model is expecting:

<!--- this is in the resources block -->
<conv:MouseEventsConverter x:Key="ClickConverter" />

<!-- and this is in the ItemControl's ItemTemplate -->
<ContentControl>
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="PreviewMouseDown">
            <cmd:EventToCommand
                Command="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:CanvasView},
                Path=DataContext.MouseDownCommand}"
                PassEventArgsToCommand="True"
                EventArgsConverter="{StaticResource ClickConverter}"
                EventArgsConverterParameter="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=local:SchedulePanel}}"/>
        </i:EventTrigger>
    </i:Interaction.Triggers>
</ContentControl>

The binding itself needs some explaining...

1) Command. This binding is made on the object that the user clicks on (e.g. your rectangles), but the command handler will typically be in the parent ItemsControl's DataContext object. So we need to use a RelativeBinding to find that object and bind to its handler.

2) Path=DataContext.MouseDownCommand. Should be pretty straightforward.

3) PassEventArgsToCommand="True". This tells EventToCommand that our handler is expecting a parameter of type MouseArgs. We could do this if we wanted, but then our view model would be messing around with GUI objects, so we need to convert it to our own type IMouseArgs.

4) EventArgsConverter="{StaticResource ClickConverter}" This converter will do that translation for us, I'll show the code below.

5) EventArgsConverterParameter="... Sometimes we need to pass other data in to our converter as an additional parameter. I won't go into all specific cases of where this is needed, but keep it in mind. In this specific case I needed to find the point relative to the ItemControl's parent instead of the ItemControl itself.

The converter class itself can have anything you like, a simple implementation would be something like this:

public class MouseEventsConverter : IEventArgsConverter
{
    public object Convert(object value, object parameter)
    {           
        var args = value as MouseEventArgs;
        var element = args.Source as FrameworkElement;
        var parent = parameter as IInputElement;
        var result = new ConverterArgs
        {
            Args = args,
            Element = element,
            Parent = parent,
            Data = element.DataContext
        };
        return result;
    }

Note that my specific implementation here (ConverterArgs) actually stores the Framework element, you can always get to it with a cast if the view model passes this object back to the view anywhere.

Make sense? It looks complex but it's actually quite straightforward once you code it up.

0
votes

Perfect solution here using behaviours: Move items in a canvas using MVVM. I tested it so it would work fine.