In short: is it correct in MVVM pattern to access main window datacontext and update it through behavior class?
long: I'm trying to learn WPF MVVM and make app where one of the functionalities is canvas with draggable ellipses. I found few examples of behaviors that could provide this functionality but they relied on TranslateTransform and this was not the solution I wanted. I want to extract the ellipse coordinates for furhter use.
I also use ItemsControl to display canvas and related items which made impossible to use Canvas.SetTop() command.
After several tries I found a working solution but I’m not sure if this is correct according to MVVM pattern. And if this is the simplest way to achieve the goal… I take up coding as a hobby if I made some concept mistakes please let me know.
Short app description:
- On app startup the instance of TestWindow2VM class is crated and assigned to main window as datacontext
- TestWindow2VM class contains ObservableCollection which contains EllipseVM class.
- EllipseVM class holds X,Y coordinates and some other data (brushes etc).
- In XAML in ItemsControl the binding of ItemsSource is set to my ObservableCollection. In ItemsControl Datatemplate I bind ellipse properties to data stored in EllipseVM class and also add reference to my behavior class
- in ItemsControl ItemContainerStyle canvas top and left properties are bound to my ObservableCollection
- when ellipse is clicked my behavior class access the datacontext, finds the instance of EllipseVM class and changes X and Y coordinates basing on mouse cursor position relative to canvas.
Code below:
behavior:
public class CanvasDragBehavior
{
private Point _mouseCurrentPos;
private Point _mouseStartOffset;
private bool _dragged;
private static CanvasDragBehavior _dragBehavior = new CanvasDragBehavior();
public static CanvasDragBehavior dragBehavior
{
get { return _dragBehavior; }
set { _dragBehavior = value; }
}
public static readonly DependencyProperty IsDragProperty =
DependencyProperty.RegisterAttached("CanBeDragged",
typeof(bool), typeof(DragBehavior),
new PropertyMetadata(false, OnDragChanged));
public static bool GetCanBeDragged(DependencyObject obj)
{
return (bool)obj.GetValue(IsDragProperty);
}
public static void SetCanBeDragged(DependencyObject obj, bool value)
{
obj.SetValue(IsDragProperty, value);
}
private static void OnDragChanged(object sender, DependencyPropertyChangedEventArgs e)
{
var element = (UIElement)sender;
var isDrag = (bool)(e.NewValue);
dragBehavior = new CanvasDragBehavior();
if (isDrag)
{
element.MouseLeftButtonDown = dragBehavior.ElementOnMouseLeftButtonDown;
element.MouseLeftButtonUp = dragBehavior.ElementOnMouseLeftButtonUp;
element.MouseMove = dragBehavior.ElementOnMouseMove;
}
else
{
element.MouseLeftButtonDown -= dragBehavior.ElementOnMouseLeftButtonDown;
element.MouseLeftButtonUp -= dragBehavior.ElementOnMouseLeftButtonUp;
element.MouseMove -= dragBehavior.ElementOnMouseMove;
}
}
private void ElementOnMouseMove(object sender, MouseEventArgs e)
{
if (!_dragged) return;
Canvas canvas = Extension.FindAncestor<Canvas>(((FrameworkElement)sender));
if (canvas != null)
{
_mouseCurrentPos = e.GetPosition(canvas);
FrameworkElement fe = (FrameworkElement)sender;
if (fe.DataContext.GetType() == typeof(EllipseVM))
{
// EllipseVM class contains X and Y coordinates that are used in ItemsControl to display the ellipse
EllipseVM ellipseVM = (EllipseVM)fe.DataContext;
double positionLeft = _mouseCurrentPos.X - _mouseStartOffset.X;
double positionTop = _mouseCurrentPos.Y - _mouseStartOffset.Y;
#region canvas border check
if (positionLeft < 0) positionLeft = 0;
if (positionTop < 0) positionTop = 0;
if (positionLeft > canvas.ActualWidth) positionLeft = canvas.ActualWidth-fe.Width;
if (positionTop > canvas.ActualHeight) positionTop = canvas.ActualHeight-fe.Height;
#endregion
ellipseVM.left = positionLeft;
ellipseVM.top = positionTop;
}
}
}
private void ElementOnMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_mouseStartOffset = e.GetPosition((FrameworkElement)sender);
_dragged = true;
((UIElement)sender).CaptureMouse();
}
private void ElementOnMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
{
_dragged = false;
((UIElement)sender).ReleaseMouseCapture();
}
XAML:
<ItemsControl ItemsSource="{Binding scrollViewElements}" >
<ItemsControl.Resources>
<!--some other data templates here-->
</DataTemplate>
<DataTemplate DataType="{x:Type VM:EllipseVM}" >
<Ellipse Width="{Binding width}"
Height="{Binding height}"
Fill="{Binding fillBrush}"
Stroke="Red" StrokeThickness="1"
behaviors:CanvasDragBehavior.CanBeDragged="True"
/>
</DataTemplate>
</ItemsControl.Resources>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas Background="Transparent" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Top" Value="{Binding top}"/>
<Setter Property="Canvas.Left" Value="{Binding left}"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
CodePudding user response:
MVVM distinct 3 kinds of object:
- View
- VModel
- Model
The property of the view should be bound to the VModel, you try correctly to bind the view with EllipseVM, like a real Expert! The issue on your project is that you have not a single View bound to a single VM, but you want an infinite number of VModels.
I will enumerate below some points of reflection:
- I would like to challenge the fact that you register on different drag events: element.MouseLeftButtonDown
you should register only when objects are created or destroyed.
CanvasDragBehavior : why do you implement a singleton like pattern with a static public property (without private constructor) ?
Avoid registering properties via string like "CanBeDragged" find a way to define and use interfaces eg IMoveFeature{bool IsDraggable}
CodePudding user response:
Your code is too complicated and has some errors.
For example the static instance property of the CanvasDragBehavior
is not required. It looks like you confused something here.
To position the element on the Canvas
simply use the attached properties Canvas.Top
and Canvas.Left
.
Prefer the tunneling version of input events, prefixed with Preview. For example listen to PreviewMouseMove
instead of MouseMove
.
Another important fix is to use the WeakEventManager
to subscribe to the events of the attached elements. Otherwise you create a potential memory leak (depending on the lifetime of the event publisher and event listener). Always remember to follow the following pattern to avoid such memory leaks: when you subscribe to events, ensure that you will always unsubscribe too. If you have no control over the object lifetime, always follow the Weak Event pattern and use the WeakEventManager
to observe events.
In your case: when an item is removed from the ItemsControl.ItemsSource
, your behavior won't be able to detect this change in order to unsubscribe from the corresponding events.
The risk of a memory leak in your context is not high, but better be safe than sorry and stick to the safety pattern.
When implementing a control or behavior, try to avoid tight coupling to data types and implementation details. Make the control or behavior as generic as possible. For this reason, your behavior should not know about the DataContext
and what type of elements are dragged. This way you can simply extend your code or reuse the behavior for example to allow to drag a Rectangle
too. Right now, your code only works with a Ellipse
or EllipseVM
.
Usually, you don't need the position data in your view model. If it's pure UI drag&Drop the coordinates are part of the view only. In this case you would prefer to attach the behavior to the item container instead of attaching it to the elements of the DataTemplate
: you don't want to drag the data model. You want to drag the item container.
If you still need the coordinates in your model, you would setup a binding in the ItemsControl.ItemContainerStyle
like in the example below (the DataContext
of the item container Style
is always the data item, which is of type EllipseVM
in your case).
The simplified and improved version that targets dragging the item container rather than the data model could look as follows. Note that the following behavior is implemented by only using the UIElement
type for the dragged object. The actual type of the element or the data model is not required at all. This way it will work with every shape or control (not only Ellipse
). You can even drag a Button
or whatever is defined in the DataTemplate
. The DataContext
can be any type.
public class CanvasDragBehavior
{
public static readonly DependencyProperty IsDragEnabledProperty = DependencyProperty.RegisterAttached(
"IsDragEnabled",
typeof(bool),
typeof(CanvasDragBehavior),
new PropertyMetadata(false, OnIsDragEnabledChanged));
public static bool GetIsDragEnabled(DependencyObject obj) => (bool)obj.GetValue(IsDragEnabledProperty);
public static void SetIsDragEnabled(DependencyObject obj, bool value) => obj.SetValue(IsDragEnabledProperty, value);
private static Point DragStartPosition { get; set; }
private static ConditionalWeakTable<UIElement, UIElement> ItemToItemHostMap { get; } = new ConditionalWeakTable<UIElement, UIElement>();
private static void OnIsDragEnabledChanged(object attachingElement, DependencyPropertyChangedEventArgs e)
{
if (attachingElement is not UIElement uiElement)
{
return;
}
var isEnabled = (bool)e.NewValue;
if (isEnabled)
{
WeakEventManager<UIElement, MouseButtonEventArgs>.AddHandler(uiElement, nameof(uiElement.PreviewMouseLeftButtonDown), OnDraggablePreviewMouseLeftButtonDown);
WeakEventManager<UIElement, MouseEventArgs>.AddHandler(uiElement, nameof(uiElement.PreviewMouseMove), OnDraggablePreviewMouseMove);
WeakEventManager<UIElement, MouseButtonEventArgs>.AddHandler(uiElement, nameof(uiElement.PreviewMouseLeftButtonUp), OnDraggablePreviewMouseLeftButtonUp);
}
else
{
WeakEventManager<UIElement, MouseButtonEventArgs>.RemoveHandler(uiElement, nameof(uiElement.PreviewMouseLeftButtonDown), OnDraggablePreviewMouseLeftButtonDown);
WeakEventManager<UIElement, MouseEventArgs>.RemoveHandler(uiElement, nameof(uiElement.PreviewMouseMove), OnDraggablePreviewMouseMove);
WeakEventManager<UIElement, MouseButtonEventArgs>.RemoveHandler(uiElement, nameof(uiElement.PreviewMouseLeftButtonUp), OnDraggablePreviewMouseLeftButtonUp);
}
}
private static void OnDraggablePreviewMouseMove(object sender, MouseEventArgs e)
{
if (e.MouseDevice.LeftButton == MouseButtonState.Released)
{
return;
}
var draggable = sender as UIElement;
if (!ItemToItemHostMap.TryGetValue(draggable, out UIElement draggableHost))
{
return;
}
Point newDragEndPosition = e.GetPosition(draggableHost);
newDragEndPosition.Offset(-DragStartPosition.X, -DragStartPosition.Y);
Canvas.SetLeft(draggable, newDragEndPosition.X);
Canvas.SetTop(draggable, newDragEndPosition.Y);
}
private static void OnDraggablePreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
var draggable = sender as UIElement;
if (!ItemToItemHostMap.TryGetValue(draggable, out _))
{
if (!TryGetVisualParent(draggable, out Panel draggableHost))
{
return;
}
ItemToItemHostMap.Add(draggable, draggableHost);
}
DragStartPosition = e.GetPosition(draggable);
draggable.CaptureMouse();
}
private static void OnDraggablePreviewMouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> (sender as UIElement)?.ReleaseMouseCapture();
private static bool TryGetVisualParent<TParent>(DependencyObject element, out TParent parent) where TParent : DependencyObject
{
parent = null;
if (element is null)
{
return false;
}
element = VisualTreeHelper.GetParent(element);
if (element is TParent parentElement)
{
parent = parentElement;
return true;
}
return TryGetVisualParent(element, out parent);
}
}
Usage example
MainWindow.xaml
<Window>
<ItemsControl ItemsSource="{Binding scrollViewElements}"
Height="1000"
Width="1000">
<ItemsControl.Resources>
<DataTemplate DataType="{x:Type VM:EllipseVM}">
<Ellipse Width="50"
Height="50"
Fill="Red"
Stroke="Black"
StrokeThickness="1" />
</DataTemplate>
</ItemsControl.Resources>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas Width="{Binding RelativeSource={RelativeSource AncestorType=ItemsControl}, Path=ActualWidth}"
Height="{Binding RelativeSource={RelativeSource AncestorType=ItemsControl}, Path=ActualHeight}"
Background="Gray" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="local:CanvasDragBehavior.IsDragEnabled"
Value="true" />
<!-- Optional binding if you need the coordinates in the view model.
This example assumes that the view model has a Top and Left property -->
<Setter Property="Canvas.Top"
Value="{Binding Top, Mode=TwoWay}" />
<Setter Property="Canvas.Left"
Value="{Binding Left, Mode=TwoWay}" />
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
</Window>