You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

WPF MVVM实现Pin与Wire位置绑定:拖拽Pin同步Wire起点

咱们一步步来拆解你的问题,把MVVM模式自然适配到你的Pin/Wire图场景里——先明确角色划分,再看具体实现,最后解决序列化和动态添加的问题。

梳理MVVM角色划分:Model vs ViewModel

首先肯定你的疑问:是的,你需要分别为Pin和Wire创建Model和ViewModel,两者职责完全不同:

  • Model:纯数据载体,只存需要持久化的信息(比如Pin的位置、Wire的关联Pin ID和贝塞尔控制点),实现INotifyPropertyChanged以便通知数据变化。
  • ViewModel:包裹Model,处理UI交互逻辑(比如Pin的拖拽)、依赖关系(比如Wire关联Pin的位置监听),对外暴露供View绑定的属性和命令,同样实现INotifyPropertyChanged。
Pin的MVVM实现:拖拽与位置同步

先从Pin入手,这部分是交互的核心:

PinModel(数据层)

存Pin的唯一标识和位置,是序列化的核心:

public class PinModel : INotifyPropertyChanged
{
    private double _x;
    private double _y;
    public Guid Id { get; set; } = Guid.NewGuid();

    public double X
    {
        get => _x;
        set { _x = value; OnPropertyChanged(); }
    }

    public double Y
    {
        get => _y;
        set { _y = value; OnPropertyChanged(); }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string prop = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}

PinViewModel(逻辑层)

处理拖拽命令,把View的拖拽事件转化为Model的位置更新:

public class PinViewModel : INotifyPropertyChanged
{
    private readonly PinModel _model;

    public PinViewModel(PinModel model)
    {
        _model = model;
        _model.PropertyChanged += (s, e) => OnPropertyChanged(e.PropertyName);
        DragCommand = new RelayCommand<DragDeltaEventArgs>(ExecuteDrag);
    }

    public Guid Id => _model.Id;
    public double X { get => _model.X; set => _model.X = value; }
    public double Y { get => _model.Y; set => _model.Y = value; }
    public ICommand DragCommand { get; }

    private void ExecuteDrag(DragDeltaEventArgs e)
    {
        X += e.HorizontalChange;
        Y += e.VerticalChange;
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string prop = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}

PinView(UI层)

修改你的UserControl,用Blend行为把DragDelta事件绑定到ViewModel的命令,避免代码后置:

<UserControl x:Class="WireMvvm.Pin" 
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
             xmlns:cmd="http://www.galasoft.ch/mvvmlight">
    <Thumb Background="Red" Width="10" Height="10">
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="DragDelta">
                <cmd:EventToCommand Command="{Binding DragCommand}" PassEventArgsToCommand="True"/>
            </i:EventTrigger>
        </i:Interaction.Triggers>
    </Thumb>
</UserControl>
Wire的MVVM实现:同步Pin位置

Wire的核心是依赖Pin的位置动态更新路径,所以ViewModel需要监听关联Pin的变化:

WireModel(数据层)

存Wire的关联Pin ID和贝塞尔控制点,不存动态计算的起点/终点:

public class WireModel
{
    public Guid StartPinId { get; set; }
    public Guid EndPinId { get; set; }
    public Point BezierPoint1 { get; set; }
    public Point BezierPoint2 { get; set; }
}

WireViewModel(逻辑层)

监听Pin的位置变化,动态生成PathGeometry供View绑定:

public class WireViewModel : INotifyPropertyChanged
{
    private readonly WireModel _model;
    private readonly ObservableCollection<PinViewModel> _allPins;
    private PinViewModel _startPin;
    private PinViewModel _endPin;

    public WireViewModel(WireModel model, ObservableCollection<PinViewModel> allPins)
    {
        _model = model;
        _allPins = allPins;

        // 初始化时找到关联Pin
        _startPin = _allPins.FirstOrDefault(p => p.Id == _model.StartPinId);
        _endPin = _allPins.FirstOrDefault(p => p.Id == _model.EndPinId);

        // 监听Pin位置变化
        _startPin?.PropertyChanged += OnPinPositionChanged;
        _endPin?.PropertyChanged += OnPinPositionChanged;

        // 监听Pin集合变化,支持动态添加Pin后关联
        _allPins.CollectionChanged += OnPinsCollectionChanged;
    }

    // 动态计算起点/终点
    public Point StartPoint => _startPin != null ? new Point(_startPin.X, _startPin.Y) : new Point(0,0);
    public Point EndPoint => _endPin != null ? new Point(_endPin.X, _endPin.Y) : new Point(0,0);
    public Point BezierPoint1 => _model.BezierPoint1;
    public Point BezierPoint2 => _model.BezierPoint2;

    // 生成完整的PathGeometry供View绑定
    public PathGeometry WireGeometry
    {
        get
        {
            var figure = new PathFigure
            {
                StartPoint = StartPoint,
                Segments = new PathSegmentCollection
                {
                    new BezierSegment(BezierPoint1, BezierPoint2, EndPoint, true)
                }
            };
            return new PathGeometry(new PathFigureCollection { figure });
        }
    }

    private void OnPinPositionChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName is nameof(PinViewModel.X) or nameof(PinViewModel.Y))
        {
            OnPropertyChanged(nameof(StartPoint));
            OnPropertyChanged(nameof(EndPoint));
            OnPropertyChanged(nameof(WireGeometry));
        }
    }

    private void OnPinsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems == null) return;
        foreach (var item in e.NewItems)
        {
            var pin = item as PinViewModel;
            if (pin?.Id == _model.StartPinId)
            {
                _startPin = pin;
                _startPin.PropertyChanged += OnPinPositionChanged;
                OnPropertyChanged(nameof(StartPoint));
                OnPropertyChanged(nameof(WireGeometry));
            }
            else if (pin?.Id == _model.EndPinId)
            {
                _endPin = pin;
                _endPin.PropertyChanged += OnPinPositionChanged;
                OnPropertyChanged(nameof(EndPoint));
                OnPropertyChanged(nameof(WireGeometry));
            }
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string prop = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));
}
主ViewModel:管理动态添加与序列化

创建GraphViewModel来统筹所有Pin和Wire,处理动态添加和序列化逻辑:

public class GraphViewModel
{
    public ObservableCollection<PinViewModel> Pins { get; } = new();
    public ObservableCollection<WireViewModel> Wires { get; } = new();

    public ICommand AddPinCommand { get; }
    public ICommand AddWireCommand { get; }

    public GraphViewModel()
    {
        AddPinCommand = new RelayCommand(AddPin);
        AddWireCommand = new RelayCommand(AddWire);
    }

    private void AddPin()
    {
        var pinModel = new PinModel { X = 50, Y = 50 };
        Pins.Add(new PinViewModel(pinModel));
    }

    private void AddWire()
    {
        if (Pins.Count < 2) return;
        var wireModel = new WireModel
        {
            StartPinId = Pins[0].Id,
            EndPinId = Pins[1].Id,
            BezierPoint1 = new Point(100,100),
            BezierPoint2 = new Point(100,50)
        };
        Wires.Add(new WireViewModel(wireModel, Pins));
    }

    // 序列化:只序列化Model层数据
    public void Serialize(string path)
    {
        var pinModels = Pins.Select(p => new PinModel { Id = p.Id, X = p.X, Y = p.Y }).ToList();
        var wireModels = Wires.Select(w => new WireModel 
        { 
            StartPinId = w.StartPinId, 
            EndPinId = w.EndPinId, 
            BezierPoint1 = w.BezierPoint1, 
            BezierPoint2 = w.BezierPoint2 
        }).ToList();
        var graphData = new { Pins = pinModels, Wires = wireModels };
        File.WriteAllText(path, JsonConvert.SerializeObject(graphData));
    }

    // 反序列化:从Model重建ViewModel
    public void Deserialize(string path)
    {
        var json = File.ReadAllText(path);
        var graphData = JsonConvert.DeserializeObject<dynamic>(json);
        Pins.Clear();
        foreach (var pinData in graphData.Pins)
        {
            var pinModel = new PinModel
            {
                Id = Guid.Parse(pinData.Id.ToString()),
                X = pinData.X,
                Y = pinData.Y
            };
            Pins.Add(new PinViewModel(pinModel));
        }
        Wires.Clear();
        foreach (var wireData in graphData.Wires)
        {
            var wireModel = new WireModel
            {
                StartPinId = Guid.Parse(wireData.StartPinId.ToString()),
                EndPinId = Guid.Parse(wireData.EndPinId.ToString()),
                BezierPoint1 = new Point(wireData.BezierPoint1.X, wireData.BezierPoint1.Y),
                BezierPoint2 = new Point(wireData.BezierPoint2.X, wireData.BezierPoint2.Y)
            };
            Wires.Add(new WireViewModel(wireModel, Pins));
        }
    }
}
主View:绑定与渲染

最后在主Window里用ItemsControl绑定集合,用Canvas作为容器:

<Window x:Class="WireMvvm.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WireMvvm"
        Title="Wire Graph" Height="450" Width="800">
    <Window.DataContext>
        <local:GraphViewModel/>
    </Window.DataContext>
    <DockPanel>
        <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="5">
            <Button Content="Add Pin" Command="{Binding AddPinCommand}" Margin="5"/>
            <Button Content="Add Wire" Command="{Binding AddWireCommand}" Margin="5"/>
            <Button Content="Save" Click="Save_Click" Margin="5"/>
            <Button Content="Load" Click="Load_Click" Margin="5"/>
        </StackPanel>
        <Canvas>
            <!-- 先渲染Wire,再渲染Pin,确保Pin在上方 -->
            <ItemsControl ItemsSource="{Binding Wires}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate><Canvas/></ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemTemplate>
                    <DataTemplate>
                        <Path Stroke="Red" StrokeThickness="2" Data="{Binding WireGeometry}"/>
                    </DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
            <ItemsControl ItemsSource="{Binding Pins}">
                <ItemsControl.ItemsPanel>
                    <ItemsPanelTemplate><Canvas/></ItemsPanelTemplate>
                </ItemsControl.ItemsPanel>
                <ItemsControl.ItemContainerStyle>
                    <Style>
                        <Setter Property="Canvas.Left" Value="{Binding X}"/>
                        <Setter Property="Canvas.Top" Value="{Binding Y}"/>
                    </Style>
                </ItemsControl.ItemContainerStyle>
                <ItemsControl.ItemTemplate>
                    <DataTemplate><local:Pin/></DataTemplate>
                </ItemsControl.ItemTemplate>
            </ItemsControl>
        </Canvas>
    </DockPanel>
</Window>

主Window的代码后置处理保存加载:

public partial class MainWindow : Window
{
    private GraphViewModel ViewModel => DataContext as GraphViewModel;

    public MainWindow() => InitializeComponent();

    private void Save_Click(object sender, RoutedEventArgs e)
    {
        var saveDialog = new SaveFileDialog { Filter = "JSON Files (*.json)|*.json" };
        if (saveDialog.ShowDialog() == true)
            ViewModel.Serialize(saveDialog.FileName);
    }

    private void Load_Click(object sender, RoutedEventArgs e)
    {
        var openDialog = new OpenFileDialog { Filter = "JSON Files (*.json)|*.json" };
        if (openDialog.ShowDialog() == true)
            ViewModel.Deserialize(openDialog.FileName);
    }
}
关键亮点总结
  • 解耦:Wire和Pin通过ID关联,不直接持有引用,方便序列化和动态添加。
  • 自动同步:WireViewModel监听Pin的位置变化,自动更新路径,无需手动事件绑定。
  • 动态支持:用ObservableCollection管理Pin和Wire,添加/删除时UI自动更新。
  • 序列化友好:只序列化纯数据的Model层,ViewModel从Model重建,避免序列化复杂的UI逻辑。

内容的提问来源于stack exchange,提问作者Joao Vitor

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.28 10:49:12