Search code examples
c#wpfxamldata-bindingwpf-controls

How to implement a WPF control that is moved by the mouse, like a slider control but 2D?


I want to control the rendering of a QuadraticBezierSegment at runtime via its control point, Q (Point1 property of the Segment). I am able to do this with separate slider controls for the X and Y values of the point. But I ultimately want to be able to use drag the control point around to reshape the segment. In the code below I can draw the control point and the segment and they both respond the the sliders. But I can't figure out how to drag the point around to control the segment (I would then dispense with the sliders).

enter image description here

Currently there is no code behind, I'm trying to keep everything in XAML/MVVM but not sure if that's possible. Thanks.

Here is the ViewModel:

namespace BezierDemo
{
class MainViewModel : INotifyPropertyChanged
{
    private System.Windows.Point _q;

    private double _qy;
    private double _qx;

    public MainViewModel()
    {
        _q.X = 50;
        _q.Y = 0;
    }

    // https://www.danrigby.com/2015/09/12/inotifypropertychanged-the-net-4-6-way/

    public event PropertyChangedEventHandler PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        if (Equals(storage, value))
        {
            return false;
        }

        storage = value;
        this.OnPropertyChanged(propertyName);
        return true;
    }

    public double QX
    {
        get { return _q.X; }
        set { Q = new System.Windows.Point(value, Q.Y); SetProperty(ref this._qx, value); }

    }

    public double QY
    {
        get { return _q.Y; }
        set { Q = new System.Windows.Point(Q.X, value); SetProperty(ref this._qy, value); }
    }

    public System.Windows.Point Q
    {
        get { return _q; }
        set { SetProperty(ref this._q, value); }
    }
}
}

...and here is the XAML:

<Window x:Class="BezierDemo.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:BezierDemo"
    mc:Ignorable="d"
    Title="MainWindow" Height="450" Width="506">
<Window.DataContext>
    <local:MainViewModel/>
</Window.DataContext>
<Grid>

    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="400*"/>
        <ColumnDefinition Width="100*"/>
    </Grid.ColumnDefinitions>

    <!-- Bezier Control Point -->
    <Canvas Grid.Column="0">
        <Ellipse Width="5" Height="5" Fill="Indigo" Stroke="Indigo" Cursor="Hand" >
            <Ellipse.RenderTransform>
                <TranslateTransform X="{Binding Path=QX}" Y="{Binding Path=QY}"/>
            </Ellipse.RenderTransform>
            <Ellipse.Triggers>
                <EventTrigger RoutedEvent="Ellipse.MouseMove">

                </EventTrigger>
            </Ellipse.Triggers>
        </Ellipse>
    </Canvas>

    <!-- QuadraticBezierSegment -->
    <Path Stroke="Black" Fill="Gray" Grid.Column="0">
        <Path.Data>
            <PathGeometry>
                <PathFigure>
                    <PathFigure.StartPoint>
                        <Point X="0" Y="100" />
                    </PathFigure.StartPoint>
                    <QuadraticBezierSegment Point1="{Binding Path=Q}" Point2="100, 100" />
                </PathFigure>
            </PathGeometry>
        </Path.Data>
    </Path>

    <!-- X & Y Slider Controls -->
    <Grid Grid.Column="2" >
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="*"/>
        </Grid.ColumnDefinitions>
        <StackPanel Grid.Column="0" VerticalAlignment="Center">
            <Slider Name="X" Orientation="Vertical" Maximum="100" HorizontalAlignment="Center" VerticalAlignment="Center" Height="234" Value="{Binding Path=QX}" Minimum="0.0"/>
            <Label HorizontalAlignment="Center">X</Label>
        </StackPanel>
        <StackPanel Grid.Column="1" VerticalAlignment="Center">
            <Slider Name="Y" Orientation="Vertical" Maximum="100" HorizontalAlignment="Center" VerticalAlignment="Center" Height="234" Value="{Binding Path=QY}" Minimum="0.0"/>
            <Label HorizontalAlignment="Center">Y</Label>
        </StackPanel>
    </Grid>

</Grid>

Solution

  • If you wrap your Bezier Control Point in a Thumb element, then you can accomplish what you want quite easily.

    <!-- Bezier Control Point -->
    <Canvas Grid.Column="0">
        <Thumb DragDelta="Thumb_DragDelta" Canvas.Left="{Binding QX, Mode=TwoWay}" Canvas.Top="{Binding QY, Mode=TwoWay}">
            <Thumb.Template>
                <ControlTemplate>
                   <Ellipse Width="5" Height="5" Fill="Indigo" Stroke="Indigo" />
                </ControlTemplate>
            </Thumb.Template>
        </Thumb>
    </Canvas>
    

    Note: I had to add Panel.ZIndex="-1" to the QuadraticBezierSegment so the ellipse would render in front of the Bezier segment. Or you could move the thumb part after the bezier segment declaration.

    The code-behind:

    private void Thumb_DragDelta(object sender, System.Windows.Controls.Primitives.DragDeltaEventArgs e)
    {
        UIElement thumb = e.Source as UIElement;
    
        Canvas.SetLeft(thumb, Canvas.GetLeft(thumb) + e.HorizontalChange);
        Canvas.SetTop(thumb, Canvas.GetTop(thumb) + e.VerticalChange);
    }
    

    You can convert the code behind to an event handler in your viewmodel by using the Microsoft.Xaml.Behaviors.Wpf nuget package.

    That would look like this

    <Canvas Grid.Column="0">
        <Thumb Canvas.Left="{Binding QX, Mode=TwoWay}" Canvas.Top="{Binding QY, Mode=TwoWay}">
            <b:Interaction.Triggers>
                <b:EventTrigger EventName="DragDelta">
                    <b:InvokeCommandAction Command="{Binding HandleDragDelta}" PassEventArgsToCommand="True" />
                </b:EventTrigger>
            </b:Interaction.Triggers>
            <Thumb.Template>
                <ControlTemplate>
                    <Ellipse Width="5" Height="5" Fill="Indigo" Stroke="Indigo" />
                </ControlTemplate>
            </Thumb.Template>
        </Thumb>
    </Canvas>
    

    Where HandleDragDelta is a some sort of ICommand implementation that can take the DragDeltaEventArgs parameter because you'll need it.

    private DelegateCommand<DragDeltaEventArgs> handleDragDelta;
    public ICommand HandleDragDelta => handleDragDelta ??= new DelegateCommand<DragDeltaEventArgs>(PerformHandleDragDelta);
    
    private void PerformHandleDragDelta(DragDeltaEventArgs e)
    {
        UIElement thumb = e.Source as UIElement;
    
        Canvas.SetLeft(thumb, Canvas.GetLeft(thumb) + e.HorizontalChange);
        Canvas.SetTop(thumb, Canvas.GetTop(thumb) + e.VerticalChange);
    }