Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Prevent WPF controls from overlapping on MouseMove event

I'm working on a dynamic C# WPF application (on Windows 10) that uses a fullscreen Grid. Controls are added to the grid dynamically at runtime (which are managed in a Dictionary<>) and I recently added code to move the controls along the grid with the mouse (also at runtime) using a TranslateTransform (which I am now doubting the viability of).

Is there a way I can prevent the controls from overlapping or "sharing space" on the grid when moving them? In other words, adding some sort of collision detection. Would I use an if statement to check the control margin ranges or something? My move events are shown below:

MainWindow.xaml.cs:

public partial class MainWindow : Window
{
     // Orientation variables:
     public bool _isInDrag = false;
     public Dictionary<object, TranslateTransform> PointDict = new Dictionary<object, TranslateTransform();
     public Point _anchorPoint;
     public Point _currentPoint;

     public MainWindow()
     {
          InitializeComponent();
     }

    public static void Control_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
    {
        if (_isInDrag)
        {
            var element = sender as FrameworkElement;
            element.ReleaseMouseCapture();
            _isInDrag = false;
            e.Handled = true;
        }           
    }

    public static void Control_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
    {
         var element = sender as FrameworkElement;
         _anchorPoint = e.GetPosition(null);
         element.CaptureMouse();
         _isInDrag = true;
         e.Handled = true;
    }

    public static void Control_MouseMove(object sender, MouseEventArgs e)
    {
        if (_isInDrag)
        {
            _currentPoint = e.GetPosition(null);
            TranslateTransform tt = new TranslateTransform();
            bool isMoved = false;
            if (PointDict.ContainsKey(sender))
            {
                tt = PointDict[sender];
                isMoved = true;
            }
            tt.X += _currentPoint.X - _anchorPoint.X;
            tt.Y += (_currentPoint.Y - _anchorPoint.Y);
            (sender as UIElement).RenderTransform = tt;
            _anchorPoint = _currentPoint;
            if (isMoved)
            {
                PointDict.Remove(sender);
            }
            PointDict.Add(sender, tt);
        }
   }
}

MainWindow.xaml (example):

<Window x:Name="MW" x:Class="MyProgram.MainWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    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:local="clr-namespace:MyProgram"
    mc:Ignorable="d"
    Title="MyProgram" d:DesignHeight="1080" d:DesignWidth="1920" ResizeMode="NoResize" WindowState="Maximized" WindowStyle="None">

    <Grid x:Name="MyGrid" />
        <Image x:Name="Image1" Source="pic.png" Margin="880,862,0,0" Height="164" Width="162" HorizontalAlignment="Left" VerticalAlignment="Top" MouseLeftButtonDown="Control_MouseLeftButtonDown" MouseLeftButtonUp="Control_MouseLeftButtonUp" MouseMove="Control_MouseMove" />
        <TextBox x:Name="Textbox1" Margin="440,560,0,0" HorizontalAlignment="Left" VerticalAlignment="Top" MouseLeftButtonDown="Control_MouseLeftButtonDown" MouseLeftButtonUp="Control_MouseLeftButtonUp" MouseMove="Control_MouseMove" />
</Window>

Edit: It seems that moving a control with a TranslateTransform does not change the margin for that control. Not sure why.

Edit 2: Not getting much traction. If anyone needs clarification on anything, please ask.

Edit 3: Pretty sure I can't use TranslateTransform because it does not change the margin of a given control. Is there an alternative?

Edit 4: Added some 'boilerplate' code for those who want to copy & paste. Let me know if you have any questions about it.

like image 693
Luke Dinkler Avatar asked Oct 18 '22 02:10

Luke Dinkler


1 Answers

TL;DR: Demo from the bottom of this answer

When you want to modify your UI without adding event handlers to every single control, the way to go is with Adorners. Adorners are (as the name implies) controls that adorn another control to add additional visuals or as in your case functionality. Adorners reside in an AdornerLayer which you can either add yourself or use the one that every WPF Window already has. The AdornerLayer is on top of all your other controls.

You never mentioned what should happen when the user lets go of the mouse button when controls overlap so I just reset the control to its original position if that happens.

At this point I'd usually explain what to keep in mind when moving controls but since your original example even contains the CaptureMouse people usually forget, I think you'll understand the code without further explanation :)

A couple of things you might want to add / improve:

  • A snap to grid feature (pixel precise movement can be a bit overwhelming for the average user)
  • Take RenderTransform, LayoutTransform and non-rectangular shapes (if needed) into account when calculating the overlap
  • Move the editing functionality (enable, disable, etc.) into a separate control and add a dedicated AdornerLayer
  • Disable interactive controls (Buttons, TextBoxes, ComboBoxes, etc.) in edit-mode
  • Cancel movement when the user presses Esc
  • Restrict movement to the bounds of the parent container done
  • Move the active Adorner to the top of the AdornerLayer
  • Let the user move multiple controls at once (typically by selecting them with Ctrl)

Previously unanswered question:

Are you saying controls are no longer assigned a margin when using TranslateTransform?

Not at all - You could use a combination of Grid.Row, Grid.Column, Margin, RenderTransform and LayoutTransform but then it would be a nightmare to determine where the control is actually displayed. If you stick with one (In this case for example Margin or LayoutTransform) it is much easier to work with and keep track of. If you ever find yourself in a situation where you need more than one at the same time, you would have to find the actual position by determining the corners of the control by transforming (0, 0) and (ActualWidth, ActualHeight) with TransformToAncestor. Trust me, you don't want to go there - keep it simple, stick with one of them.

The below code is not the "holy grail of how to move things" but it should give you an idea of how to do it and what else you could do with it (resize, rotate, remove controls, etc.). The layouting is based purely on the Left and Top margin of the controls. It shouldn't be to hard to swap out all Margins for LayoutTransforms if you prefer that, as long as you keep it consistent.

Move Adorner

using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;

public class MoveAdorner : Adorner
{
    // The parent of the adorned Control, in your case a Grid
    private readonly Panel _parent;
    // Same as "AdornedControl" but as a FrameworkElement
    private readonly FrameworkElement _child;

    // The visual overlay rectangle we can click and drag
    private readonly Rectangle _rect;
    // Our own collection of child elements, in this example only _rect
    private readonly UIElementCollection _visualChildren;

    private bool _down;
    private Point _downPos;
    private Thickness _downMargin;

    private List<Rect> _otherRects;

    protected override int VisualChildrenCount => _visualChildren.Count;

    protected override Visual GetVisualChild(int index)
    {
        return _visualChildren[index];
    }

    public MoveAdorner(FrameworkElement adornedElement) : base(adornedElement)
    {
        _child = adornedElement;
        _parent = adornedElement.Parent as Panel;
        _visualChildren = new UIElementCollection(this,this);
        _rect = new Rectangle
        {
            HorizontalAlignment = HorizontalAlignment.Stretch,
            VerticalAlignment = VerticalAlignment.Stretch,
            StrokeThickness = 1,
        };

        SetColor(Colors.LightGray);

        _rect.MouseLeftButtonDown += RectOnMouseLeftButtonDown;
        _rect.MouseLeftButtonUp += RectOnMouseLeftButtonUp;
        _rect.MouseMove += RectOnMouseMove;

        _visualChildren.Add(_rect);
    }

    private void SetColor(Color color)
    {
        _rect.Fill = new SolidColorBrush(color) {Opacity = 0.3};
        _rect.Stroke = new SolidColorBrush(color) {Opacity = 0.5};
    }

    private void RectOnMouseMove(object sender, MouseEventArgs args)
    {
        if (!_down) return;

        Point pos = args.GetPosition(_parent);
        UpdateMargin(pos);
    }

    private void UpdateMargin(Point pos)
    {
        double deltaX = pos.X - _downPos.X;
        double deltaY = pos.Y - _downPos.Y;

        Thickness newThickness = new Thickness(_downMargin.Left + deltaX, _downMargin.Top + deltaY, 0, 0);

        //Restrict to parent's bounds
        double leftMax = _parent.ActualWidth - _child.ActualWidth;
        double topMax = _parent.ActualHeight - _child.ActualHeight;

        newThickness.Left = Math.Max(0, Math.Min(newThickness.Left, leftMax));
        newThickness.Top = Math.Max(0, Math.Min(newThickness.Top, topMax));

        _child.Margin = newThickness;

        bool overlaps = CheckForOverlap();

        SetColor(overlaps ? Colors.Red : Colors.Green);
    }

    // Check the current position for overlaps with all other controls
    private bool CheckForOverlap()
    {
        if (_otherRects == null || _otherRects.Count == 0)
            return false;

        Rect thisRect = GetRect(_child);
        foreach(Rect otherRect in _otherRects)
            if (thisRect.IntersectsWith(otherRect))
                return true;

        return false;
    }

    private Rect GetRect(FrameworkElement element)
    {
        return new Rect(new Point(element.Margin.Left, element.Margin.Top), new Size(element.ActualWidth, element.ActualHeight));
    }

    private void RectOnMouseLeftButtonUp(object sender, MouseButtonEventArgs args)
    {
        if (!_down) return;

        Point pos = args.GetPosition(_parent);

        UpdateMargin(pos);

        if (CheckForOverlap())
            ResetMargin();

        _down = false;
        _rect.ReleaseMouseCapture();
        SetColor(Colors.LightGray);
    }

    private void ResetMargin()
    {
        _child.Margin = _downMargin;
    }

    private void RectOnMouseLeftButtonDown(object sender, MouseButtonEventArgs args)
    {
        _down = true;
        _rect.CaptureMouse();
        _downPos = args.GetPosition(_parent);
        _downMargin = _child.Margin;

        // The current position of all other elements doesn't have to be updated
        // while we move this one so we only determine it once
        _otherRects = new List<Rect>();
        foreach (FrameworkElement child in _parent.Children)
        {
            if (ReferenceEquals(child, _child))
                continue;
            _otherRects.Add(GetRect(child));
        }
    }

    // Whenever the adorned control is resized or moved
    // Update the size of the overlay rectangle
    // (Not 100% necessary as long as you only move it)
    protected override Size MeasureOverride(Size constraint)
    {
        _rect.Measure(constraint);
        return base.MeasureOverride(constraint);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        _rect.Arrange(new Rect(new Point(0,0), finalSize));
        return base.ArrangeOverride(finalSize);
    }
}

Usage

private void DisableEditing(Grid theGrid)
{
    // Remove all Adorners of all Controls
    foreach (FrameworkElement child in theGrid.Children)
    {
        var layer = AdornerLayer.GetAdornerLayer(child);
        var adorners = layer.GetAdorners(child);
        if (adorners == null)
            continue;
        foreach(var adorner in adorners)
            layer.Remove(adorner);
    }
}

private void EnableEditing(Grid theGrid)
{
    foreach (FrameworkElement child in theGrid.Children)
    {
        // Add a MoveAdorner for every single child
        Adorner adorner = new MoveAdorner(child);

        // Add the Adorner to the closest (hierarchically speaking) AdornerLayer
        AdornerLayer.GetAdornerLayer(child).Add(adorner);
    }
}

Demo XAML

<Grid>
    <Button Content="Enable Editing" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="100" Click="BtnEnable_Click"/>
    <Button Content="Disable Editing" HorizontalAlignment="Left" Margin="115,10,0,0" VerticalAlignment="Top" Width="100" Click="BtnDisable_Click"/>

    <Grid Name="grid" Background="AliceBlue" Margin="10,37,10,10">
        <Button Content="Button" HorizontalAlignment="Left" Margin="83,44,0,0" VerticalAlignment="Top" Width="75"/>
        <Ellipse Fill="#FFF4F4F5" HorizontalAlignment="Left" Height="100" Margin="207,100,0,0" Stroke="Black" VerticalAlignment="Top" Width="100"/>
        <Rectangle Fill="#FFF4F4F5" HorizontalAlignment="Left" Height="100" Margin="33,134,0,0" Stroke="Black" VerticalAlignment="Top" Width="100"/>
    </Grid>
</Grid>

Expected Result

When editing is disabled controls cannot be moved, interactive controls can be clicked / interacted with without obstruction. When editing mode is enabled, each control is overlayed with an adorner that can be moved. If the target position overlaps with another control, the adorner will turn red and the margin will be reset to the initial position if the user lets go of the mouse button.

Quick Demo

like image 95
Manfred Radlwimmer Avatar answered Nov 03 '22 05:11

Manfred Radlwimmer