打造WPF项目管理工具:甘特图(Gantt Chart)实战指南

打造WPF项目管理工具:甘特图(Gantt Chart)实战指南

【免费下载链接】HandyControl Contains some simple and commonly used WPF controls 【免费下载链接】HandyControl 项目地址: https://gitcode.com/gh_mirrors/ha/HandyControl

引言:项目管理的可视化痛点与解决方案

你是否还在为跟踪WPF项目进度而烦恼?Excel表格难以直观展示任务依赖关系,传统时间线工具缺乏交互性,团队协作时进度更新滞后?本文将使用HandyControl控件库,从零构建一个功能完备的甘特图(Gantt Chart)组件,帮助开发者在WPF应用中实现专业级项目进度可视化。

读完本文你将掌握:

  • 甘特图核心数据结构设计与任务依赖表达
  • 使用Canvas与自定义Panel实现时间轴与任务条渲染
  • 基于HandyControl扩展控件实现交互功能(拖拽、缩放、进度更新)
  • 完整项目管理工具的整合方案与性能优化策略

甘特图核心原理与架构设计

甘特图(Gantt Chart)概念解析

甘特图是一种水平条形图(Horizontal Bar Chart),由亨利·L·甘特(Henry L. Gantt)在1917年发明,用于展示项目进度与任务时间关系。在WPF环境中,甘特图通常包含以下核心元素:

mermaid

核心技术选型与项目结构

基于HandyControl控件库,我们将采用以下技术组合实现甘特图:

功能模块技术实现HandyControl组件支持
时间轴渲染Canvas + 自定义绘制ScrollViewer、Watermark
任务行布局自定义Panel(GanttPanel)RelativePanel、SimplePanel
任务条控件自定义UserControlBadge、ProgressButton
交互功能拖拽行为(Interaction)DragElementBehavior
数据管理ObservableCollection-
样式主题ResourceDictionaryTheme、Brushes

数据模型设计:任务与时间轴的核心结构

GanttTask数据模型实现

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Media;

namespace HandyControlDemo.ViewModel
{
    public enum TaskStatus
    {
        NotStarted,
        InProgress,
        Completed,
        Delayed,
        OnHold
    }

    public class GanttTask : INotifyPropertyChanged
    {
        private string _id;
        private string _name;
        private DateTime _start;
        private DateTime _end;
        private double _progress;
        private string _resource;
        private TaskStatus _status;
        private Brush _color;

        public string Id
        {
            get => _id;
            set { _id = value; OnPropertyChanged(); }
        }

        public string Name
        {
            get => _name;
            set { _name = value; OnPropertyChanged(); }
        }

        public DateTime Start
        {
            get => _start;
            set { _start = value; OnPropertyChanged(); OnPropertyChanged(nameof(Duration)); }
        }

        public DateTime End
        {
            get => _end;
            set { _end = value; OnPropertyChanged(); OnPropertyChanged(nameof(Duration)); }
        }

        public double Duration => (End - Start).TotalDays;

        public double Progress
        {
            get => _progress;
            set { _progress = Math.Clamp(value, 0, 1); OnPropertyChanged(); }
        }

        public string Resource
        {
            get => _resource;
            set { _resource = value; OnPropertyChanged(); }
        }

        public ObservableCollection<string> Dependencies { get; set; } = new();

        public TaskStatus Status
        {
            get => _status;
            set { _status = value; OnPropertyChanged(); OnPropertyChanged(nameof(Color)); }
        }

        public Brush Color
        {
            get => _color ?? GetDefaultColor();
            set { _color = value; OnPropertyChanged(); }
        }

        private Brush GetDefaultColor()
        {
            return Status switch
            {
                TaskStatus.InProgress => Brushes.DodgerBlue,
                TaskStatus.Completed => Brushes.LimeGreen,
                TaskStatus.Delayed => Brushes.OrangeRed,
                TaskStatus.OnHold => Brushes.Gray,
                _ => Brushes.LightGray
            };
        }

        public event PropertyChangedEventHandler PropertyChanged;

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

时间轴(TimeAxis)计算引擎

时间轴是甘特图的核心基础设施,负责将日期时间映射为画布坐标:

public class TimeAxis
{
    public enum TimeUnit { Day, Week, Month, Quarter, Year }

    public TimeUnit Unit { get; set; } = TimeUnit.Day;
    public double CellWidth { get; set; } = 60; // 每个时间单位的像素宽度
    public DateTime StartDate { get; private set; }
    public DateTime EndDate { get; private set; }
    public List<DateTime> MajorTicks { get; } = new();
    public List<DateTime> MinorTicks { get; } = new();

    public void UpdateRange(DateTime start, DateTime end, double viewportWidth)
    {
        StartDate = start;
        EndDate = end;
        
        // 自动调整时间单位以适应视图宽度
        var totalDays = (end - start).TotalDays;
        if (totalDays > 365 && viewportWidth / totalDays < 0.5)
            Unit = TimeUnit.Month;
        else if (totalDays > 60 && viewportWidth / totalDays < 2)
            Unit = TimeUnit.Week;
        else
            Unit = TimeUnit.Day;

        CalculateTicks();
    }

    public void CalculateTicks()
    {
        MajorTicks.Clear();
        MinorTicks.Clear();

        var current = StartDate.Date;
        while (current <= EndDate)
        {
            MajorTicks.Add(current);
            
            // 添加次要刻度
            switch (Unit)
            {
                case TimeUnit.Month:
                    AddWeekTicks(current);
                    break;
                case TimeUnit.Week:
                    AddDailyTicks(current, 7);
                    break;
                case TimeUnit.Day:
                    AddHourTicks(current);
                    break;
            }

            current = AddUnit(current);
        }
    }

    // 日期到像素位置的转换
    public double GetPosition(DateTime date)
    {
        var totalUnits = Unit switch
        {
            TimeUnit.Day => (EndDate - StartDate).TotalDays,
            TimeUnit.Week => (EndDate - StartDate).TotalDays / 7,
            TimeUnit.Month => (EndDate.Year - StartDate.Year) * 12 + EndDate.Month - StartDate.Month,
            _ => (EndDate - StartDate).TotalDays
        };

        var dateValue = Unit switch
        {
            TimeUnit.Day => (date - StartDate).TotalDays,
            TimeUnit.Week => (date - StartDate).TotalDays / 7,
            TimeUnit.Month => (date.Year - StartDate.Year) * 12 + date.Month - StartDate.Month,
            _ => (date - StartDate).TotalDays
        };

        return (dateValue / totalUnits) * (CellWidth * GetUnitsPerMajorTick());
    }

    // 像素位置到日期的转换
    public DateTime GetDate(double position)
    {
        var totalWidth = CellWidth * GetUnitsPerMajorTick();
        var ratio = position / totalWidth;
        var totalSpan = EndDate - StartDate;
        return StartDate.AddTicks((long)(totalSpan.Ticks * ratio));
    }

    private DateTime AddUnit(DateTime date) => Unit switch
    {
        TimeUnit.Day => date.AddDays(1),
        TimeUnit.Week => date.AddDays(7),
        TimeUnit.Month => date.AddMonths(1),
        TimeUnit.Quarter => date.AddMonths(3),
        TimeUnit.Year => date.AddYears(1),
        _ => date.AddDays(1)
    };

    private int GetUnitsPerMajorTick() => Unit switch
    {
        TimeUnit.Day => 1,
        TimeUnit.Week => 7,
        TimeUnit.Month => DateTime.DaysInMonth(StartDate.Year, StartDate.Month),
        _ => 1
    };

    // 辅助方法:添加次要刻度
    private void AddDailyTicks(DateTime start, int count)
    {
        for (var i = 1; i < count; i++)
        {
            MinorTicks.Add(start.AddDays(i));
        }
    }

    private void AddWeekTicks(DateTime monthStart)
    {
        var monthEnd = monthStart.AddMonths(1).AddDays(-1);
        var firstDay = monthStart;
        while (firstDay.DayOfWeek != DayOfWeek.Monday) firstDay = firstDay.AddDays(1);
        
        var current = firstDay;
        while (current < monthEnd)
        {
            MinorTicks.Add(current);
            current = current.AddDays(7);
        }
    }

    private void AddHourTicks(DateTime day)
    {
        for (var i = 1; i < 24; i += 6) // 每6小时一个次要刻度
        {
            MinorTicks.Add(day.AddHours(i));
        }
    }
}

甘特图控件实现:从画布到交互

自定义GanttPanel布局面板

基于HandyControl的Panel扩展能力,实现甘特图的任务布局:

using System.Windows;
using System.Windows.Controls;
using HandyControl.Controls;

public class GanttPanel : Panel
{
    public static readonly DependencyProperty RowHeightProperty = DependencyProperty.Register(
        nameof(RowHeight), typeof(double), typeof(GanttPanel), 
        new PropertyMetadata(40.0, OnRowHeightChanged));

    public static readonly DependencyProperty TimeAxisProperty = DependencyProperty.Register(
        nameof(TimeAxis), typeof(TimeAxis), typeof(GanttPanel),
        new PropertyMetadata(null, OnTimeAxisChanged));

    public double RowHeight
    {
        get => (double)GetValue(RowHeightProperty);
        set => SetValue(RowHeightProperty, value);
    }

    public TimeAxis TimeAxis
    {
        get => (TimeAxis)GetValue(TimeAxisProperty);
        set => SetValue(TimeAxisProperty, value);
    }

    private static void OnRowHeightChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((GanttPanel)d).InvalidateArrange();
    }

    private static void OnTimeAxisChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((GanttPanel)d).InvalidateMeasure();
    }

    protected override Size MeasureOverride(Size availableSize)
    {
        var totalHeight = Children.Count * RowHeight;
        var totalWidth = TimeAxis?.CellWidth * 30 ?? availableSize.Width; // 默认30个时间单位宽度

        foreach (var child in Children)
        {
            if (child is FrameworkElement element && element.DataContext is GanttTask task)
            {
                // 测量任务条宽度
                var taskWidth = TimeAxis.GetPosition(task.End) - TimeAxis.GetPosition(task.Start);
                element.Measure(new Size(taskWidth, RowHeight - 8)); // 留出边距
            }
            else
            {
                child.Measure(availableSize);
            }
        }

        return new Size(totalWidth, totalHeight);
    }

    protected override Size ArrangeOverride(Size finalSize)
    {
        if (TimeAxis == null) return finalSize;

        for (var i = 0; i < Children.Count; i++)
        {
            var child = Children[i];
            if (child is FrameworkElement element && element.DataContext is GanttTask task)
            {
                var x = TimeAxis.GetPosition(task.Start);
                var width = TimeAxis.GetPosition(task.End) - x;
                var y = i * RowHeight + 4; // 顶部边距

                element.Arrange(new Rect(x, y, width, RowHeight - 8));
            }
            else
            {
                child.Arrange(new Rect(0, i * RowHeight, finalSize.Width, RowHeight));
            }
        }

        return finalSize;
    }
}

甘特图主控件XAML实现

结合Canvas和GanttPanel,构建完整的甘特图视图:

<UserControl x:Class="HandyControlDemo.UserControl.GanttChartControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:hc="https://handyorg.github.io/handycontrol"
             xmlns:local="clr-namespace:HandyControlDemo.UserControl"
             xmlns:vm="clr-namespace:HandyControlDemo.ViewModel"
             DataContext="{Binding GanttViewModel, Source={StaticResource Locator}}">
    <Grid>
        <!-- 头部时间轴 -->
        <Grid.RowDefinitions>
            <RowDefinition Height="60"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        
        <!-- 时间轴头部 -->
        <ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Disabled">
            <Canvas x:Name="TimeAxisCanvas" Height="60">
                <!-- 主要刻度线和标签 -->
                <ItemsControl ItemsSource="{Binding TimeAxis.MajorTicks}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate DataType="{x:Type sys:DateTime}">
                            <StackPanel Orientation="Vertical" 
                                       Canvas.Left="{Binding Path=., Converter={StaticResource TimeToXConverter}}">
                                <Line Y1="0" Y2="10" Stroke="Gray" StrokeThickness="1"/>
                                <TextBlock Text="{Binding StringFormat='MM/dd'}" Margin="0,10,0,0"
                                           HorizontalAlignment="Center"/>
                            </StackPanel>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
                
                <!-- 次要刻度线 -->
                <ItemsControl ItemsSource="{Binding TimeAxis.MinorTicks}">
                    <ItemsControl.ItemTemplate>
                        <DataTemplate DataType="{x:Type sys:DateTime}">
                            <Line Canvas.Left="{Binding Path=., Converter={StaticResource TimeToXConverter}}"
                                  Y1="0" Y2="5" Stroke="LightGray" StrokeThickness="1"/>
                        </DataTemplate>
                    </ItemsControl.ItemTemplate>
                </ItemsControl>
                
                <!-- 今天标记线 -->
                <Line Canvas.Left="{Binding TodayPosition}" Y1="0" Y2="60" 
                      Stroke="Red" StrokeThickness="2" StrokeDashArray="4 2"/>
            </Canvas>
        </ScrollViewer>
        
        <!-- 主内容区域 -->
        <ScrollViewer Grid.Row="1" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
                      x:Name="MainScrollViewer" ScrollChanged="OnScrollChanged">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="200"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                
                <!-- 任务名称列 -->
                <Border Grid.Column="0" Background="#F5F5F5" BorderThickness="0,0,1,0" BorderBrush="LightGray">
                    <ItemsControl ItemsSource="{Binding Tasks}">
                        <ItemsControl.ItemTemplate>
                            <DataTemplate DataType="{x:Type vm:GanttTask}">
                                <Grid Height="{Binding RowHeight, Source={StaticResource GanttPanel}}">
                                    <Border BorderThickness="0,0,0,1" BorderBrush="LightGray">
                                        <StackPanel Margin="10">
                                            <TextBlock Text="{Binding Name}" FontWeight="Medium"/>
                                            <TextBlock Text="{Binding Resource}" FontSize="12" Foreground="Gray"/>
                                        </StackPanel>
                                    </Border>
                                </Grid>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </Border>
                
                <!-- 甘特图任务区域 -->
                <local:GanttPanel Grid.Column="1" x:Name="GanttPanel"
                                  Tasks="{Binding Tasks}" TimeAxis="{Binding TimeAxis}"
                                  RowHeight="60">
                    <!-- 背景网格线 -->
                    <Canvas>
                        <!-- 水平线 -->
                        <ItemsControl ItemsSource="{Binding Tasks}">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Line Y1="{Binding Path=Index, Converter={StaticResource RowToYConverter}}" 
                                          Y2="{Binding Path=Index, Converter={StaticResource RowToYConverter}}"
                                          X2="{Binding MaxWidth}" Stroke="LightGray" StrokeThickness="1"/>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                        
                        <!-- 垂直线 (绑定到次要刻度) -->
                        <ItemsControl ItemsSource="{Binding TimeAxis.MinorTicks}">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate>
                                    <Line X1="{Binding Path=., Converter={StaticResource TimeToXConverter}}"
                                          X2="{Binding Path=., Converter={StaticResource TimeToXConverter}}"
                                          Y2="{Binding TotalHeight}" Stroke="LightGray" StrokeThickness="0.5"/>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </Canvas>
                    
                    <!-- 任务条 -->
                    <ItemsControl ItemsSource="{Binding Tasks}">
                        <ItemsControl.ItemTemplate>
                            <DataTemplate DataType="{x:Type vm:GanttTask}">
                                <local:GanttTaskBar Task="{Binding}" 
                                                   DragDelta="OnTaskBarDragDelta"
                                                   ProgressChanged="OnTaskProgressChanged"/>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                    
                    <!-- 依赖关系线 -->
                    <Canvas>
                        <ItemsControl ItemsSource="{Binding DependencyLines}">
                            <ItemsControl.ItemTemplate>
                                <DataTemplate DataType="{x:Type vm:DependencyLine}">
                                    <Path Data="{Binding Path=., Converter={StaticResource DependencyToPathConverter}}"
                                          Stroke="Gray" StrokeThickness="1.5" StrokeDashArray="5 3"/>
                                </DataTemplate>
                            </ItemsControl.ItemTemplate>
                        </ItemsControl>
                    </Canvas>
                </local:GanttPanel>
            </Grid>
        </ScrollViewer>
    </Grid>
</UserControl>

任务条控件与交互实现

using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using HandyControl.Controls;
using HandyControlDemo.ViewModel;

public class GanttTaskBar : UserControl
{
    public static readonly DependencyProperty TaskProperty = DependencyProperty.Register(
        nameof(Task), typeof(GanttTask), typeof(GanttTaskBar), 
        new FrameworkPropertyMetadata(OnTaskChanged));

    public static readonly DependencyProperty ProgressProperty = DependencyProperty.Register(
        nameof(Progress), typeof(double), typeof(GanttTaskBar), 
        new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault));

    public GanttTask Task
    {
        get => (GanttTask)GetValue(TaskProperty);
        set => SetValue(TaskProperty, value);
    }

    public double Progress
    {
        get => (double)GetValue(ProgressProperty);
        set => SetValue(ProgressProperty, value);
    }

    public event RoutedEventHandler ProgressChanged;
    public event DragDeltaEventHandler DragDelta;

    private Border _taskBorder;
    private Grid _progressGrid;
    private Thumb _dragThumb;
    private Thumb _progressThumb;

    public GanttTaskBar()
    {
        DefaultStyleKey = typeof(GanttTaskBar);
        Loaded += OnLoaded;
    }

    private static void OnTaskChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (GanttTaskBar)d;
        var task = (GanttTask)e.NewValue;
        control.Progress = task.Progress;
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        _taskBorder = GetTemplateChild("TaskBorder") as Border;
        _progressGrid = GetTemplateChild("ProgressGrid") as Grid;
        _dragThumb = GetTemplateChild("DragThumb") as Thumb;
        _progressThumb = GetTemplateChild("ProgressThumb") as Thumb;

        if (_dragThumb != null)
        {
            _dragThumb.DragDelta += (s, e) => DragDelta?.Invoke(this, e);
        }

        if (_progressThumb != null)
        {
            _progressThumb.DragDelta += OnProgressDragDelta;
        }

        UpdateVisualState();
    }

    private void OnProgressDragDelta(object sender, DragDeltaEventArgs e)
    {
        // 计算新进度
        var parent = VisualTreeHelper.GetParent(this) as Canvas;
        if (parent == null) return;

        var taskWidth = _taskBorder.ActualWidth;
        if (taskWidth <= 0) return;

        // 计算进度(相对于任务条宽度的比例)
        var delta = e.HorizontalChange / taskWidth;
        Progress = Math.Clamp(Progress + delta, 0, 1);

        // 更新进度条宽度
        _progressGrid.Width = _taskBorder.ActualWidth * Progress;

        // 触发进度变化事件
        ProgressChanged?.Invoke(this, new RoutedEventArgs());
    }

    private void UpdateVisualState()
    {
        if (_taskBorder == null || Task == null) return;

        // 设置任务条颜色
        _taskBorder.Background = Task.Color;
        
        // 设置进度条颜色(比任务条颜色深一些)
        if (_progressGrid != null && _progressGrid.Children.Count > 0 
            && _progressGrid.Children[0] is Border progressBorder)
        {
            progressBorder.Background = new SolidColorBrush(
                Color.FromArgb(
                    255, 
                    (byte)(Task.Color.GetValue(Brush.ColorProperty).R * 0.8),
                    (byte)(Task.Color.GetValue(Brush.ColorProperty).G * 0.8),
                    (byte)(Task.Color.GetValue(Brush.ColorProperty).B * 0.8)
                )
            );
        }
    }

    public override void OnApplyTemplate()
    {
        base.OnApplyTemplate();
        UpdateVisualState();
    }
}

甘特图样式与主题整合

HandyControl主题适配

通过HandyControl的资源字典,使甘特图融入整体应用风格:

<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:HandyControlDemo.UserControl">
    
    <Style TargetType="local:GanttTaskBar">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local:GanttTaskBar">
                    <Border x:Name="TaskBorder" 
                            Background="{Binding Task.Color}" 
                            CornerRadius="3"
                            BorderBrush="Gray"
                            BorderThickness="1"
                            Height="30"
                            Margin="2">
                        <Grid x:Name="ProgressGrid" HorizontalAlignment="Left" Background="{Binding Task.Color, Converter={StaticResource DarkenBrushConverter}}">
                            <StackPanel Orientation="Horizontal" Margin="5">
                                <TextBlock Text="{Binding Task.Name}" Foreground="White" 
                                           VerticalAlignment="Center" MaxWidth="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=Border}}"/>
                            </StackPanel>
                            <Thumb x:Name="ProgressThumb" Width="10" HorizontalAlignment="Right"
                                   Background="White" BorderBrush="Gray" BorderThickness="1"
                                   Cursor="Hand">
                                <Thumb.Style>
                                    <Style TargetType="Thumb">
                                        <Setter Property="Template">
                                            <Setter.Value>
                                                <ControlTemplate TargetType="Thumb">
                                                    <Border Background="{TemplateBinding Background}"
                                                            BorderBrush="{TemplateBinding BorderBrush}"
                                                            BorderThickness="{TemplateBinding BorderThickness}"
                                                            CornerRadius="1"/>
                                                </ControlTemplate>
                                            </Setter.Value>
                                        </Setter>
                                    </Style>
                                </Thumb.Style>
                            </Thumb>
                        </Grid>
                        <Thumb x:Name="DragThumb" Background="Transparent"/>
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
    
    <!-- 时间到X坐标的转换器 -->
    <local:TimeToXConverter x:Key="TimeToXConverter"/>
    
    <!-- 行索引到Y坐标的转换器 -->
    <local:RowToYConverter x:Key="RowToYConverter"/>
    
    <!-- 依赖关系到路径的转换器 -->
    <local:DependencyToPathConverter x:Key="DependencyToPathConverter"/>
    
    <!-- 颜色加深转换器 -->
    <local:DarkenBrushConverter x:Key="DarkenBrushConverter"/>
</ResourceDictionary>

交互逻辑实现

// 甘特图用户控件的后台代码
public partial class GanttChartControl : UserControl
{
    public GanttChartControl()
    {
        InitializeComponent();
        DataContextChanged += OnDataContextChanged;
    }

    private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
    {
        if (DataContext is GanttViewModel vm)
        {
            vm.PropertyChanged += (s, args) =>
            {
                if (args.PropertyName == nameof(vm.TimeAxis))
                {
                    vm.TimeAxis.MajorTicks.CollectionChanged += (s, e) => UpdateTimeAxis();
                    vm.TimeAxis.MinorTicks.CollectionChanged += (s, e) => UpdateTimeAxis();
                    UpdateTimeAxis();
                }
            };
        }
    }

    private void UpdateTimeAxis()
    {
        TimeAxisCanvas.Width = GanttPanel.DesiredSize.Width;
        TodayPosition = GanttPanel.TimeAxis.GetPosition(DateTime.Today);
    }

    private void OnScrollChanged(object sender, ScrollChangedEventArgs e)
    {
        // 同步时间轴和任务区域的水平滚动
        TimeAxisCanvas.RenderTransform = new TranslateTransform(-MainScrollViewer.HorizontalOffset, 0);
    }

    private void OnTaskBarDragDelta(object sender, DragDeltaEventArgs e)
    {
        var taskBar = (GanttTaskBar)sender;
        var task = taskBar.Task;
        if (task == null) return;

        // 计算时间偏移量(像素到天数的转换)
        var daysPerPixel = (GanttPanel.TimeAxis.EndDate - GanttPanel.TimeAxis.StartDate).TotalDays / 
                          GanttPanel.TimeAxis.CellWidth;
        var dayOffset = e.HorizontalChange * daysPerPixel;

        // 更新任务时间
        task.Start = task.Start.AddDays(dayOffset);
        task.End = task.End.AddDays(dayOffset);

        // 刷新布局
        GanttPanel.InvalidateArrange();
    }

    private void OnTaskProgressChanged(object sender, RoutedEventArgs e)
    {
        var taskBar = (GanttTaskBar)sender;
        var task = taskBar.Task;
        if (task != null)
        {
            task.Progress = taskBar.Progress;
            
            // 根据进度自动更新状态
            if (task.Progress >= 1)
                task.Status = TaskStatus.Completed;
            else if (task.Progress > 0)
                task.Status = TaskStatus.InProgress;
        }
    }

    public double TodayPosition { get; set; }
}

功能扩展与性能优化

高级功能实现

1. 任务依赖关系管理
public class DependencyManager
{
    private readonly ObservableCollection<GanttTask> _tasks;
    private readonly ObservableCollection<DependencyLine> _dependencyLines;
    private readonly TimeAxis _timeAxis;
    private double _rowHeight;

    public DependencyManager(ObservableCollection<GanttTask> tasks, 
                            ObservableCollection<DependencyLine> dependencyLines,
                            TimeAxis timeAxis, double rowHeight)
    {
        _tasks = tasks;
        _dependencyLines = dependencyLines;
        _timeAxis = timeAxis;
        _rowHeight = rowHeight;

        _tasks.CollectionChanged += OnTasksChanged;
        foreach (var task in _tasks)
        {
            task.PropertyChanged += OnTaskPropertyChanged;
            task.Dependencies.CollectionChanged += (s, e) => UpdateDependencies();
        }
    }

    private void OnTasksChanged(object sender, NotifyCollectionChangedEventArgs e)
    {
        if (e.NewItems != null)
        {
            foreach (GanttTask task in e.NewItems)
            {
                task.PropertyChanged += OnTaskPropertyChanged;
                task.Dependencies.CollectionChanged += (s, e) => UpdateDependencies();
            }
        }

        if (e.OldItems != null)
        {
            foreach (GanttTask task in e.OldItems)
            {
                task.PropertyChanged -= OnTaskPropertyChanged;
                task.Dependencies.CollectionChanged -= (s, e) => UpdateDependencies();
            }
        }

        UpdateDependencies();
    }

    private void OnTaskPropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName is nameof(GanttTask.Start) or nameof(GanttTask.End))
        {
            UpdateDependencies();
        }
    }

    public void UpdateDependencies()
    {
        _dependencyLines.Clear();

        for (var i = 0; i < _tasks.Count; i++)
        {
            var task = _tasks[i];
            foreach (var dependencyId in task.Dependencies)
            {
                var predecessor = _tasks.FirstOrDefault(t => t.Id == dependencyId);
                if (predecessor == null) continue;

                var predecessorIndex = _tasks.IndexOf(predecessor);
                if (predecessorIndex == -1) continue;

                // 创建依赖线对象
                _dependencyLines.Add(new DependencyLine
                {
                    StartTask = predecessor,
                    EndTask = task,
                    StartRow = predecessorIndex,
                    EndRow = i
                });
            }
        }
    }
}

// 依赖线数据结构
public class DependencyLine
{
    public GanttTask StartTask { get; set; }
    public GanttTask EndTask { get; set; }
    public int StartRow { get; set; }
    public int EndRow { get; set; }
}

// 依赖线到路径的转换器
public class DependencyToPathConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
    {
        var line = (DependencyLine)value;
        if (line.StartTask == null || line.EndTask == null) return null;

        // 获取甘特图面板
        var ganttPanel = parameter as GanttPanel;
        if (ganttPanel == null) return null;

        // 计算开始点(前置任务的结束点)
        var startX = ganttPanel.TimeAxis.GetPosition(line.StartTask.End);
        var startY = line.StartRow * ganttPanel.RowHeight + ganttPanel.RowHeight / 2;

        // 计算结束点(当前任务的开始点)
        var endX = ganttPanel.TimeAxis.GetPosition(line.EndTask.Start);
        var endY = line.EndRow * ganttPanel.RowHeight + ganttPanel.RowHeight / 2;

        // 创建贝塞尔曲线路径
        var pathGeometry = new PathGeometry();
        var pathFigure = new PathFigure { StartPoint = new Point(startX, startY) };

        // 添加贝塞尔曲线段
        var controlPoint1X = startX + Math.Abs(endX - startX) / 3;
        var controlPoint2X = endX - Math.Abs(endX - startX) / 3;

        pathFigure.Segments.Add(new BezierSegment(
            new Point(controlPoint1X, startY),
            new Point(controlPoint2X, endY),
            new Point(endX, endY),
            true));

        pathGeometry.Figures.Add(pathFigure);
        return pathGeometry;
    }

    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
    {
        throw new NotImplementedException();
    }
}
2. 甘特图缩放控制
public class ZoomController
{
    private readonly TimeAxis _timeAxis;
    private double _currentZoom = 1.0;
    private readonly double _minZoom = 0.5;
    private readonly double _maxZoom = 3.0;
    private readonly double _zoomStep = 0.2;

    public ZoomController(TimeAxis timeAxis)
    {
        _timeAxis = timeAxis;
    }

    public double ZoomLevel
    {
        get => _currentZoom;
        set
        {
            _currentZoom = Math.Clamp(value, _minZoom, _maxZoom);
            UpdateZoom();
        }
    }

    public void ZoomIn() => ZoomLevel += _zoomStep;
    public void ZoomOut() => ZoomLevel -= _zoomStep;
    public void ResetZoom() => ZoomLevel = 1.0;

    private void UpdateZoom()
    {
        // 调整时间轴的单元格宽度
        _timeAxis.CellWidth = 60 * _currentZoom;
        
        // 刷新时间轴
        _timeAxis.CalculateTicks();
    }

    // 支持鼠标滚轮缩放
    public void HandleMouseWheel(MouseWheelEventArgs e)
    {
        if (e.Delta > 0)
            ZoomIn();
        else
            ZoomOut();
    }
}

性能优化策略

1. 数据虚拟化

对于大型项目(超过100个任务),使用HandyControl的VirtualizingPanel实现数据虚拟化:

<!-- 优化的任务列表控件 -->
<ItemsControl ItemsSource="{Binding Tasks}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <hc:VirtualizingPanel IsVirtualizing="True" 
                                 VirtualizationMode="Recycling"
                                 CacheLengthUnit="Item"
                                 CacheLength="10"/>
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <!-- ItemTemplate 保持不变 -->
</ItemsControl>
2. 按需加载与缓存
public class GanttDataService
{
    private readonly ObservableCollection<GanttTask> _tasks;
    private readonly IProjectRepository _repository;
    private bool _isLoading;
    private DateTime _lastLoadDate;
    private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);

    public GanttDataService(ObservableCollection<GanttTask> tasks, IProjectRepository repository)
    {
        _tasks = tasks;
        _repository = repository;
    }

    public async Task LoadTasksAsync(string projectId, DateTime startDate, DateTime endDate)
    {
        // 检查缓存
        if (DateTime.Now - _lastLoadDate < _cacheDuration && _tasks.Count > 0)
            return;

        if (_isLoading) return;
        _isLoading = true;

        try
        {
            // 显示加载状态
            _tasks.Clear();
            _tasks.Add(new GanttTask { Name = "Loading tasks..." });

            // 异步加载数据
            var tasks = await _repository.GetTasksAsync(projectId, startDate, endDate);
            
            // 更新UI
            _tasks.Clear();
            foreach (var task in tasks)
            {
                _tasks.Add(task);
            }

            _lastLoadDate = DateTime.Now;
        }
        catch (Exception ex)
        {
            _tasks.Clear();
            _tasks.Add(new GanttTask { Name = $"Error loading tasks: {ex.Message}" });
        }
        finally
        {
            _isLoading = false;
        }
    }

    // 实现增量加载
    public async Task LoadMoreTasksAsync(DateTime newEndDate)
    {
        if (_isLoading) return;
        _isLoading = true;

        try
        {
            var lastTask = _tasks.LastOrDefault();
            if (lastTask == null) return;

            var newTasks = await _repository.GetTasksAsync(
                projectId: "current",
                startDate: lastTask.End,
                endDate: newEndDate);

            foreach (var task in newTasks)
            {
                _tasks.Add(task);
            }
        }
        finally
        {
            _isLoading = false;
        }
    }
}

项目整合与应用场景

ViewModel整合

public class GanttViewModel : ViewModelBase
{
    private readonly TimeAxis _timeAxis = new();
    private readonly ObservableCollection<GanttTask> _tasks = new();
    private readonly ObservableCollection<DependencyLine> _dependencyLines = new();
    private readonly DependencyManager _dependencyManager;
    private readonly ZoomController _zoomController;
    private readonly GanttDataService _dataService;
    private string _selectedProjectId;
    private bool _isLoading;

    public GanttViewModel()
    {
        _dependencyManager = new DependencyManager(_tasks, _dependencyLines, _timeAxis, RowHeight);
        _zoomController = new ZoomController(_timeAxis);
        _dataService = new GanttDataService(_tasks, new ProjectRepository());

        // 初始化时间轴(显示当前日期前后30天)
        var today = DateTime.Today;
        _timeAxis.UpdateRange(today.AddDays(-15), today.AddDays(45), 1000);

        // 命令初始化
        LoadSampleDataCommand = new RelayCommand(LoadSampleData);
        ZoomInCommand = new RelayCommand(() => _zoomController.ZoomIn());
        ZoomOutCommand = new RelayCommand(() => _zoomController.ZoomOut());
        ResetZoomCommand = new RelayCommand(() => _zoomController.ResetZoom());
        ScrollToTodayCommand = new RelayCommand(ScrollToToday);

        // 加载示例数据
        LoadSampleData();
    }

    public ICommand LoadSampleDataCommand { get; }
    public ICommand ZoomInCommand { get; }
    public ICommand ZoomOutCommand { get; }
    public ICommand ResetZoomCommand { get; }
    public ICommand ScrollToTodayCommand { get; }

    public TimeAxis TimeAxis => _timeAxis;
    public ObservableCollection<GanttTask> Tasks => _tasks;
    public ObservableCollection<DependencyLine> DependencyLines => _dependencyLines;
    public double RowHeight { get; set; } = 60;

    public bool IsLoading
    {
        get => _isLoading;
        set => Set(ref _isLoading, value);
    }

    public void LoadSampleData()
    {
        _tasks.Clear();
        
        var today = DateTime.Today;
        
        // 添加示例任务
        var task1 = new GanttTask
        {
            Id = "T1",
            Name = "需求分析",
            Start = today.AddDays(-10),
            End = today.AddDays(-5),
            Progress = 1,
            Resource = "产品组",
            Status = TaskStatus.Completed
        };
        
        var task2 = new GanttTask
        {
            Id = "T2",
            Name = "UI设计",
            Start = today.AddDays(-8),
            End = today.AddDays(2),
            Progress = 0.8,
            Resource = "设计组",
            Status = TaskStatus.InProgress
        };
        task2.Dependencies.Add("T1");
        
        var task3 = new GanttTask
        {
            Id = "T3",
            Name = "架构设计",
            Start = today.AddDays(-5),
            End = today.AddDays(5),
            Progress = 0.6,
            Resource = "架构师",
            Status = TaskStatus.InProgress
        };
        task3.Dependencies.Add("T1");
        
        var task4 = new GanttTask
        {
            Id = "T4",
            Name = "前端开发",
            Start = today.AddDays(1),
            End = today.AddDays(15),
            Progress = 0.3,
            Resource = "前端组",
            Status = TaskStatus.InProgress
        };
        task4.Dependencies.Add("T2");
        
        var task5 = new GanttTask
        {
            Id = "T5",
            Name = "后端开发",
            Start = today.AddDays(3),
            End = today.AddDays(20),
            Progress = 0.2,
            Resource = "后端组",
            Status = TaskStatus.InProgress
        };
        task5.Dependencies.Add("T3");
        
        var task6 = new GanttTask
        {
            Id = "T6",
            Name = "测试",
            Start = today.AddDays(16),
            End = today.AddDays(25),
            Progress = 0,
            Resource = "测试组",
            Status = TaskStatus.NotStarted
        };
        task6.Dependencies.Add("T4");
        task6.Dependencies.Add("T5");
        
        var task7 = new GanttTask
        {
            Id = "T7",
            Name = "部署上线",
            Start = today.AddDays(23),
            End = today.AddDays(28),
            Progress = 0,
            Resource = "运维组",
            Status = TaskStatus.NotStarted
        };
        task7.Dependencies.Add("T6");
        
        _tasks.Add(task1);
        _tasks.Add(task2);
        _tasks.Add(task3);
        _tasks.Add(task4);
        _tasks.Add(task5);
        _tasks.Add(task6);
        _tasks.Add(task7);
    }

    public void ScrollToToday()
    {
        // 在视图中实现滚动到今天的逻辑
        EventAggregator.Instance.SendMessage(new ScrollToTodayMessage());
    }

    public void HandleMouseWheel(MouseWheelEventArgs e)
    {
        _zoomController.HandleMouseWheel(e);
    }
}

完整应用整合

// 主窗口XAML
<Window x:Class="HandyControlDemo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:hc="https://handyorg.github.io/handycontrol"
        xmlns:local="clr-namespace:HandyControlDemo"
        Title="项目管理工具 - 甘特图视图" Height="800" Width="1200">
    <hc:WindowChrome.AllowsTransparency="True">
        <hc:WindowChrome ContentMargin="0" CornerRadius="4" GlassFrameThickness="1"/>
    </hc:WindowChrome.AllowsTransparency>
    
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        
        <!-- 工具栏 -->
        <hc:ToolBar Grid.Row="0" Background="{DynamicResource RegionBrush}">
            <hc:Button GroupName="Action" Command="{Binding GanttViewModel.LoadSampleDataCommand}" 
                       Icon="{StaticResource RefreshGeometry}" Content="刷新数据"/>
            
            <hc:Separator Margin="8,0"/>
            
            <hc:Button GroupName="Zoom" Command="{Binding GanttViewModel.ZoomInCommand}" 
                       Icon="{StaticResource ZoomInGeometry}" ToolTip="放大"/>
            <hc:Button GroupName="Zoom" Command="{Binding GanttViewModel.ZoomOutCommand}" 
                       Icon="{StaticResource ZoomOutGeometry}" ToolTip="缩小"/>
            <hc:Button GroupName="Zoom" Command="{Binding GanttViewModel.ResetZoomCommand}" 
                       Icon="{StaticResource ZoomResetGeometry}" ToolTip="重置视图"/>
            
            <hc:Separator Margin="8,0"/>
            
            <hc:Button Command="{Binding GanttViewModel.ScrollToTodayCommand}" 
                       Content="今日" Icon="{StaticResource CalendarGeometry}"/>
            
            <hc:Separator Margin="8,0"/>
            
            <hc:ComboBox Width="150" hc:InfoElement.Placeholder="选择项目">
                <hc:ComboBoxItem Content="WPF组件库开发"/>
                <hc:ComboBoxItem Content="移动端APP项目"/>
                <hc:ComboBoxItem Content="企业网站重构"/>
            </hc:ComboBox>
            
            <hc:Separator Margin="8,0" HorizontalAlignment="Stretch"/>
            
            <hc:Button Style="{StaticResource ButtonPrimary}" Content="导出甘特图" 
                       Icon="{StaticResource ExportGeometry}"/>
        </hc:ToolBar>
        
        <!-- 甘特图控件 -->
        <local:GanttChartControl Grid.Row="1" Margin="10"/>
    </Grid>
</Window>

结论与扩展方向

本文基于HandyControl控件库实现了一个功能完备的甘特图组件,包括任务展示、进度跟踪、依赖关系管理和交互操作等核心功能。通过自定义Panel和Canvas的组合使用,我们成功构建了高性能的时间轴布局系统,并通过HandyControl的Thumb、ScrollViewer等控件实现了流畅的拖拽交互体验。

进一步扩展方向

  1. 资源负载视图 - 基于现有数据扩展资源分配热力图,显示团队成员工作量分布
  2. 关键路径分析 - 实现项目关键路径算法,高亮显示影响项目总工期的任务序列
  3. 多项目视图 - 支持同时显示多个项目的甘特图,实现跨项目资源协调
  4. 导出功能 - 添加PDF/PNG导出和Microsoft Project文件格式支持
  5. 时间线比较 - 实现基线计划与实际进度的对比视图,支持进度偏差分析

甘特图作为项目管理的核心工具,其实现涉及数据结构设计、可视化渲染和交互体验等多个方面。通过本文介绍的方法,开发者可以快速将这一功能集成到WPF应用中,为用户提供专业级的项目进度管理体验。

【免费下载链接】HandyControl Contains some simple and commonly used WPF controls 【免费下载链接】HandyControl 项目地址: https://gitcode.com/gh_mirrors/ha/HandyControl

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值