WPF自定义控件ChambersOverview(附源码)
2021/7/26 11:35:39
本文主要是介绍WPF自定义控件ChambersOverview(附源码),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
背景
在很多时候我们需要用到WPF中的自定义控件,即我们想将一整套不同的控件组合成一个独立的控件并且定义在一个独立的自定义控件库中,这样整个控件就能够得到更好的封装和更好的独立性并且在定义的时候有更大的灵活性,在这篇文章中我已ItemsControl作为主体通过扩展其ItemsPanel和ItemContainerStyle以及ItemTemplate来实现一个能够对整个ItemsControl内部的Item进行移动拖拽并且改变ZIndex的独立的CustomControl,这个里面涉及到很多的概念,后面会进行一步步剖析来分析整个过程。
整体预览
过程
1 增加自定义控件库
这个熟悉自定义控件库的肯定非常熟悉,定义xaml样式和具体控件逻辑,这里有一点需要注意,就是定义的样式文件必须要加入到Generic.xaml中,否则样式无法生效,就像下面的代码描述的一样将样式xaml定义到Generic.xaml中的资源字典下面。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:ACM.Wpf.Toolkit"> <ResourceDictionary.MergedDictionaries> <ResourceDictionary Source="/ACM.Wpf.Toolkit;component/Themes/Controls/ChambersOverview.xaml"/> </ResourceDictionary.MergedDictionaries> </ResourceDictionary>
2 定义样式
这个是整个CustomControl整体的样式,这个非常关键,我们先来看具体的定义,然后再一步步进行分析。
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:ACM.Wpf.Toolkit.Controls" xmlns:data="clr-namespace:ACM.Wpf.Toolkit.Data" xmlns:converter="clr-namespace:ACM.Wpf.Toolkit.Data.Converters"> <converter:BoolToVisibilityConverter x:Key="boolToVisibilityConverter"></converter:BoolToVisibilityConverter> <converter:ChambersLocationConverter x:Key="chambersLocationConverter"></converter:ChambersLocationConverter> <converter:ChamberPositionConverter x:Key="chamberPositionConverter"></converter:ChamberPositionConverter> <Style TargetType="{x:Type controls:ChambersOverview}"> <Setter Property="Background" Value="Transparent"></Setter> <Setter Property="Margin" Value="1"></Setter> <Setter Property="Template"> <Setter.Value> <ControlTemplate> <Grid> <ItemsControl x:Name="itemsControl" Grid.Column="0" Margin="2" ItemsSource="{Binding Chambers}" MinWidth="220" MinHeight="220"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas IsItemsHost="True" Background="Transparent" /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left"> <Setter.Value> <MultiBinding Converter="{StaticResource chambersLocationConverter}" ConverterParameter="Left"> <Binding Path="Id"></Binding> <Binding Path="ChambersLocation" RelativeSource="{RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}}"></Binding> </MultiBinding> </Setter.Value> </Setter> <Setter Property="Canvas.Top"> <Setter.Value> <MultiBinding Converter="{StaticResource chambersLocationConverter}" ConverterParameter="Top"> <Binding Path="Id"></Binding> <Binding Path="ChambersLocation" RelativeSource="{RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}}"></Binding> </MultiBinding> </Setter.Value> </Setter> <Setter Property="Canvas.ZIndex"> <Setter.Value> <MultiBinding Converter="{StaticResource chambersLocationConverter}" ConverterParameter="ZIndex"> <Binding Path="Id"></Binding> <Binding Path="ChambersLocation" RelativeSource="{RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}}"></Binding> </MultiBinding> </Setter.Value> </Setter> <Setter Property="Tag" Value="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}}}"></Setter> <Setter Property="ContextMenu"> <Setter.Value> <ContextMenu Visibility="{Binding Path=PlacementTarget.Tag.IsEditable,RelativeSource={RelativeSource Self},Converter={StaticResource boolToVisibilityConverter}}"> <ContextMenu.DataContext> <MultiBinding Converter="{StaticResource chamberPositionConverter}"> <Binding Path="PlacementTarget.DataContext.Id" RelativeSource="{RelativeSource Self}"></Binding> <Binding Path="PlacementTarget.Tag.ChambersLocation" RelativeSource="{RelativeSource Self}"></Binding> </MultiBinding> </ContextMenu.DataContext> <MenuItem Header="Move Up" Command="{Binding MoveUpOneLayer}" CommandParameter="{Binding PlacementTarget, RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ContextMenu}}"></MenuItem> <MenuItem Header="Move Down" Command="{Binding MoveDownOneLayer}" CommandParameter="{Binding PlacementTarget, RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ContextMenu}}"></MenuItem> <MenuItem Header="Move Bottom" Command="{Binding MoveBottomLayer}" CommandParameter="{Binding PlacementTarget, RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ContextMenu}}"></MenuItem> <MenuItem Header="Move Front" Command="{Binding MoveFrontLayer}" CommandParameter="{Binding PlacementTarget, RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ContextMenu}}"></MenuItem> </ContextMenu> </Setter.Value> </Setter> </Style> </ItemsControl.ItemContainerStyle> <ItemsControl.ItemTemplate> <DataTemplate> <ContentPresenter ContentTemplate="{Binding ChamberControlDataTemplate,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}}}"></ContentPresenter> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> <CheckBox Content="Can Edit" HorizontalAlignment="Right" VerticalAlignment="Top" Margin="8 5" ToolTip="Is current chamberOverview can be edit?" IsChecked="{Binding IsEditable,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type controls:ChambersOverview}},Mode=TwoWay}"></CheckBox> </Grid> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary>
2.1 定义ItemsPanel
这里第一个重要的地方就是重新定义ItemsControl的ItemsPanel,我们想要让所有的Item都能够进行拖拽和移动必须首先重写这个ItemsPanel,ItemsControl默认的样式我们来看看,通过编辑ItemsControl的模板,我们可以看到ItemsControl的默认样式。
<Style x:Key="ItemsControlStyle1" TargetType="{x:Type ItemsControl}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type ItemsControl}"> <Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="true"> <ItemsPresenter SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/> </Border> </ControlTemplate> </Setter.Value> </Setter> </Style> <ItemsPanelTemplate x:Key="ItemsPanelTemplate1"> <StackPanel IsItemsHost="True"/> </ItemsPanelTemplate>
这里我们使用Canvas作为ItemsPanel的样式,另外这里的IsItemsHost必须设置为True,这个非常重要。
2.2 定义ItemsContainerStyle
这里ItemsPanel直接的子对象是ContentPresenter,而后面的ItemTemplate的视觉内容是作为ContentPresenter的子对象,这个必须要重点来理解,所以我们定义的最重要的三个属性Canvas.Left以及Canvas.Top和Canvas.ZIndex都是直接作用于ContentPresenter而不是后面的ItemTemplate中的元素,因为对于ItemsPanel来说,ContentPresenter才是其直接的子元素,如果这样还不太好理解我们在来看看这个自定义ChambersOverview控件的视觉树。
图一 ItemsControl视觉树
在上面的红框中Canvas就是我们在2.1中定义的ItemsPanel,所以这里第一个ContentPresenter才是我们定义位置和写鼠标事件的直接作用的元素,这里必须有一个清晰的认识。
2.3 定义ItemTemplate
这个部分其内部的具体内容是由使用方来定义的,所以这里我们使用了一个ContentPresenter然后其ContentTemplate绑定到我们ChambersOverview中定义的一个ChamberControlDateTemplate这个依赖项属性上面,因为这个部分要通过外部进行传入的,我们先来简单看看。
public DataTemplate ChamberControlDataTemplate { get { return (DataTemplate)GetValue(ChamberControlDataTemplateProperty); } set { SetValue(ChamberControlDataTemplateProperty, value); } } // Using a DependencyProperty as the backing store for ChamberControl. This enables animation, styling, binding, etc... public static readonly DependencyProperty ChamberControlDataTemplateProperty = DependencyProperty.Register("ChamberControlDataTemplate", typeof(DataTemplate), typeof(ChambersOverview), new PropertyMetadata(null));
3 定义控件类
这个是整个自定义控件的重中之重,在这个里面我们需要定义xaml中绑定的各种数据源,并且外部使用这个自定义控件的地方也将会数据绑定到这些依赖项属性上面,这些依赖项属性就像一个桥梁沟通具体UI元素和最终的数据源,另外整个ChamberControl的拖拽对应的事件都是在这个类里面来完成的,我们先来看整体的代码,然后再进行一步步分析整个过程。
using ACM.Wpf.Toolkit.Data.Models.Components.Chambers; using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Input; namespace ACM.Wpf.Toolkit.Controls { public class ChambersOverview : Control { private ItemsControl _itemsControl; #region Constructor static ChambersOverview() { DefaultStyleKeyProperty.OverrideMetadata(typeof(ChambersOverview), new FrameworkPropertyMetadata(typeof(ChambersOverview))); } #endregion #region Dependency Properties public bool IsEditable { get { return (bool)GetValue(IsEditableProperty); } set { SetValue(IsEditableProperty, value); } } // Using a DependencyProperty as the backing store for IsEditable. This enables animation, styling, binding, etc... public static readonly DependencyProperty IsEditableProperty = DependencyProperty.Register("IsEditable", typeof(bool), typeof(ChambersOverview), new PropertyMetadata(true)); public DataTemplate ChamberControlDataTemplate { get { return (DataTemplate)GetValue(ChamberControlDataTemplateProperty); } set { SetValue(ChamberControlDataTemplateProperty, value); } } // Using a DependencyProperty as the backing store for ChamberControl. This enables animation, styling, binding, etc... public static readonly DependencyProperty ChamberControlDataTemplateProperty = DependencyProperty.Register("ChamberControlDataTemplate", typeof(DataTemplate), typeof(ChambersOverview), new PropertyMetadata(null)); /// <summary> /// record chambers which is refer to itemsSource /// </summary> public IList Chambers { get { return (IList)GetValue(ChambersProperty); } set { SetValue(ChambersProperty, value); } } // Using a DependencyProperty as the backing store for MyProperty. This enables animation, styling, binding, etc... public static readonly DependencyProperty ChambersProperty = DependencyProperty.Register("Chambers", typeof(IList), typeof(ChambersOverview), new PropertyMetadata(null, (s, e) => { var chambersOverview = s as ChambersOverview; var contentPresenters = Utils.ControlsUtil.FindVisualChildren<ContentPresenter>(chambersOverview._itemsControl) .Where(d => d.DataContext != null).ToList(); chambersOverview._parent = Utils.ControlsUtil.FindVisualChildren<Canvas>(chambersOverview._itemsControl)?.Single(); if (contentPresenters.Any()) { var tempContentPresents = new List<ContentPresenter>(); foreach (var contentPresenter in contentPresenters) { dynamic currentDataContext = contentPresenter.DataContext; bool hasExist = false; foreach (var cpt in tempContentPresents) { dynamic cptDataContext = cpt.DataContext; if (currentDataContext.Id == cptDataContext.Id) { hasExist = true; break; } } if (hasExist) continue; //Unregister events contentPresenter.MouseLeftButtonDown -= chambersOverview.Container_MouseLeftButtonDown; contentPresenter.MouseLeftButtonUp -= chambersOverview.Container_MouseLeftButtonUp; contentPresenter.MouseMove -= chambersOverview.Container_MouseMove; contentPresenter.MouseLeave -= chambersOverview.Container_MouseLeave; //register events contentPresenter.MouseLeftButtonDown += chambersOverview.Container_MouseLeftButtonDown; contentPresenter.MouseLeftButtonUp += chambersOverview.Container_MouseLeftButtonUp; contentPresenter.MouseMove += chambersOverview.Container_MouseMove; contentPresenter.MouseLeave += chambersOverview.Container_MouseLeave; tempContentPresents.Add(contentPresenter); } } })); /// <summary> /// record chambers location in parent canvas container /// </summary> public ObservableCollection<ChamberPosition> ChambersLocation { get { return (ObservableCollection<ChamberPosition>)GetValue(ChambersLocationProperty); } set { SetValue(ChambersLocationProperty, value); } } // Using a DependencyProperty as the backing store for MyProperty. This enables animation, styling, binding, etc... public static readonly DependencyProperty ChambersLocationProperty = DependencyProperty.Register("ChambersLocation", typeof(ObservableCollection<ChamberPosition>), typeof(ChambersOverview), new PropertyMetadata(new ObservableCollection<ChamberPosition>())); #endregion #region Overrides public override void OnApplyTemplate() { base.OnApplyTemplate(); _itemsControl = this.Template.FindName("itemsControl", this) as ItemsControl; } #endregion #region Events private Canvas _parent; private bool _isDown; private Point _prePosition = new Point(); private Point _currentPosition = new Point(); private Point GetPosition(MouseEventArgs e) { return e.GetPosition(_parent); } private void Container_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { //only can drag in editable state if (IsEditable == false) return; if (_isDown) return; _isDown = true; _prePosition = GetPosition(e); (sender as ContentPresenter).CaptureMouse(); } private void Container_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { //only can drag in editable state if (IsEditable == false) return; _isDown = false; var associatedObject = sender as ContentPresenter; //refresh ChambersLocation dynamic dataContext = associatedObject.DataContext; var chamberPosition = ChambersLocation.SingleOrDefault(d => d.Id == dataContext.Id); if (null != chamberPosition) { chamberPosition.CanvasLeft = _currentPosition.X; chamberPosition.CanvasTop = _currentPosition.Y; } associatedObject.ReleaseMouseCapture(); } private void Container_MouseMove(object sender, MouseEventArgs e) { //only can drag in editable state if (IsEditable == false) return; var associatedObject = sender as ContentPresenter; associatedObject.Cursor = Cursors.SizeAll; if (!_isDown) return; Point currentPosition = GetPosition(e); double offSetX = currentPosition.X - _prePosition.X; double offSetY = currentPosition.Y - _prePosition.Y; double left = Canvas.GetLeft(associatedObject); double top = Canvas.GetTop(associatedObject); double newLeft = double.IsNaN(left) ? 0 : left + offSetX; double newTop = double.IsNaN(top) ? 0 : top + offSetY; if (newLeft == 0 && newTop == 0) return; Canvas.SetLeft(associatedObject, newLeft); Canvas.SetTop(associatedObject, newTop); _currentPosition = new Point(newLeft, newTop); _prePosition = currentPosition; } private void Container_MouseLeave(object sender, MouseEventArgs e) { //only can drag in editable state if (IsEditable == false) return; _isDown = false; var associatedObject = sender as ContentPresenter; associatedObject.Cursor = Cursors.Arrow; associatedObject.ReleaseMouseCapture(); } #endregion } }
3.1 定义数据源
这里面主要包括这几个内容:IsEdiable这个依赖项属性主要定义当前的ItemsControl中具体项是否可以进行拖拽的一个开关,Chambers这个是定义在xaml中ItemsControl绑定的ItemsSource这个是非常重要的,这个是由外部进行传入的,在具体的自定义控件库中并不知道具体的集合对象是什么,所以这里定义的类型是IList,这个是非常关键的一点,这里的类型定义我们可以参考ItemsControl中ItemsSource属性的定义,ItemsSource可以绑定到各种类型的集合对象,但是在ItemsControl的内部并不知道外部将会传入什么样的对象,所以我们来看看ItemsControl中是怎样定义ItemsSource的。
// // 摘要: // 获取或设置用于生成 System.Windows.Controls.ItemsControl 的内容的集合。 // // 返回结果: // 用于生成 System.Windows.Controls.ItemsControl 的内容的集合。 默认值为 null。 [Bindable(true)] [CustomCategoryAttribute("Content")] [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public IEnumerable ItemsSource { get; set; }
这个部分定义需要我们好好去理解,ChambersLocation是我们定义的每一个Chamber在Canvas中具体的位置以及ZIndex的信息,这个完全可以被外部绑定然后进行保存到文件中用于保留现在的位置的信息。ChamberControlDataTemplate在之前的代码中也简单说过,我们每一个ItemTemplate是根据外部传入的,这个DataTemplate是用于使用方进行绑定的。
3.2 定义拖动事件
这个在ChamberOverview中是一个非常独立的过程,前面的分析中我们说过每个Chamber拖动事件必须作用于ItemsPanel下面的直接子元素ContentPresenter,那么在这里我们怎么获取这些具体的ContentPresenter呢?这里我们首先Override基类的OnApplyTemplate()方法,在这个方法中我们能够获取到具体的ItemsControl对象,然后在Chambers依赖项属性触发回调的过程中通过一个ControlsUtil工具类找到子类的ContentPresenter,然后分别订阅Mouse事件,然后在里面更改每一个ChambersPosition位置,到此这个更改位置的过程全部结束。
3.3 定义右键ContextMenu
这个部分主要是改变每一个Chamber的ZIndex,这里面涉及到4个事件,这个都是定义在ChambersLocation这个集合中每一个具体的对象ChamberPosition中,这里我们来看看这个数据类型的具体定义。
public class ChamberPosition : ModelBase, IIdentifier { public Guid Id { get; set; } private string _groupName; public string GroupName { get { return _groupName; } set { _groupName = value; } } private string _name; public string Name { get { return _name; } set { _name = value; } } /// <summary> /// left position in canvas /// </summary> private double _canvasLeft; public double CanvasLeft { get { return _canvasLeft; } set { if (null != _canvasLeft) { _canvasLeft = value; RaisePropertyChanged(nameof(CanvasLeft)); } } } /// <summary> /// top position in canvas /// </summary> private double _canvasTop; public double CanvasTop { get { return _canvasTop; } set { if (null != _canvasTop) { _canvasTop = value; RaisePropertyChanged(nameof(CanvasTop)); } } } private int _zIndex; public int ZIndex { get { return _zIndex; } set { if (value != _zIndex) { _zIndex = value; RaisePropertyChanged(nameof(ZIndex)); } } } private ObservableCollection<ChamberPosition> _parent; [XmlIgnore] public ObservableCollection<ChamberPosition> Parent { get { return _parent; } set { if (value != _parent) { _parent = value; RaisePropertyChanged(nameof(Parent)); } } } public ChamberPosition() { MoveUpOneLayer = new DelegatedCommand(DoMoveUpOneLayer, (r) => true); MoveDownOneLayer = new DelegatedCommand(DoMoveDownOneLayer, (r) => true); MoveBottomLayer = new DelegatedCommand(DoMoveBottomLayer, (r) => true); MoveFrontLayer = new DelegatedCommand(DoMoveFrontLayer, (r) => true); } #region Commands /// <summary> /// 上移一层 /// </summary> [XmlIgnore] public ICommand MoveUpOneLayer { get; set; } /// <summary> /// 下移一层 /// </summary> [XmlIgnore] public ICommand MoveDownOneLayer { get; set; } /// <summary> /// 置于顶层 /// </summary> [XmlIgnore] public ICommand MoveFrontLayer { get; set; } /// <summary> /// 置于底层 /// </summary> [XmlIgnore] public ICommand MoveBottomLayer { get; set; } private void DoMoveFrontLayer(object obj) { if (null == Parent || Parent.Count == 0) return; var maxIndex = Parent[0].ZIndex; foreach (var item in Parent) { if (item.ZIndex > maxIndex) { maxIndex = item.ZIndex; } } var placementTarget = obj as ContentPresenter; ZIndex = ++maxIndex; Panel.SetZIndex(placementTarget, ZIndex); } private void DoMoveBottomLayer(object obj) { if (null == Parent || Parent.Count == 0) return; var minIndex = Parent[0].ZIndex; foreach (var item in Parent) { if (item.ZIndex < minIndex) { minIndex = item.ZIndex; } } var placementTarget = obj as ContentPresenter; ZIndex = --minIndex; Panel.SetZIndex(placementTarget, ZIndex); } private void DoMoveDownOneLayer(object obj) { var placementTarget = obj as ContentPresenter; ZIndex--; Panel.SetZIndex(placementTarget, ZIndex); } private void DoMoveUpOneLayer(object obj) { var placementTarget = obj as ContentPresenter; ZIndex++; Panel.SetZIndex(placementTarget, ZIndex); } #endregion }
这个里面定义了四个事件:上移一层、下移一层、置于顶层、置于底层四个命令用于绑定ContextMenu的具体命令,这里需要注意这里最终都是通过Panel.SetZIndex方法来设置每个Item在Canvas中的ZIndex的,另外关于ContextMenu如何绑定到事件是非常有技巧的,因为ContextMenu是不属于视觉树上面的元素的,但是ContextMenu的PlacementTarget是属于视觉树上面的,所以我们可以充分利用这个属性来进行绑定,另外在绑定的时候合理利用Tag属性也是非常重要的一个技巧,这个需要自己在平时的编码过程中进行不断积累和总结。
4 外部使用自定义控件
这里在自定义控件库中定义好了以后是要供外部进行调用的,我们来看看在外部我们该如何进行使用的,这里我们直接来看示例代码。
<Border x:Name="borderChamberView" Canvas.Left="80" Canvas.Top="10" BorderThickness="1" BorderBrush="White" CornerRadius="10"> <acmToolkit:ChambersOverview Grid.Column="0" Width="{Binding ActualWidth,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type UserControl},AncestorLevel=1}, Converter={StaticResource convMath}, ConverterParameter=x-90}" Height="{Binding ChambersLocation,Converter={StaticResource chambersOverviewHeightConverter},ConverterParameter=80}" local:TransferableView.WaferWillPlace="ChamberView_AnyWafer_WillBePlaced" local:TransferableView.WaferWillPick ="ChamberView_AnyWafer_WillBePicked" ChambersLocation="{Binding ChambersLocation}" Chambers="{Binding Chambers}"> <acmToolkit:ChambersOverview.ChamberControlDataTemplate> <DataTemplate> <local:ProcessModuleView Width="60" Height="60" Chamber="{Binding}" InEditMode="{Binding IsEditable,RelativeSource={RelativeSource Mode=FindAncestor,AncestorType={x:Type acmToolkit:ChambersOverview}}}" Margin="5 15" Tag="{Binding ElementName=canvas}" Background="Transparent"> </local:ProcessModuleView> </DataTemplate> </acmToolkit:ChambersOverview.ChamberControlDataTemplate> </acmToolkit:ChambersOverview> </Border>
总结
这里将整个如何创建自定义控件库、定义控件样式、重写模板、添加事件以及到最终使用全部分析了一遍,当然整个过程理解整个思路是最关键的部分,当然按这篇文章中略过了一些不太重要的细节例如这些转换器实现等等,如果还需要了解完整的过程,请点击这里下载源码。
这篇关于WPF自定义控件ChambersOverview(附源码)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-11-20获取apk的md5值有哪些方法?-icode9专业技术文章分享
- 2024-11-20xml报文没有传 IdentCode ,为什么正常解析没报错呢?-icode9专业技术文章分享
- 2024-11-20如何知道代码有没有进行 Schema 验证?-icode9专业技术文章分享
- 2024-11-20Mycat教程:新手快速入门指南
- 2024-11-20WebSocket入门:轻松掌握WebSocket基础
- 2024-11-19WebSocket入门指南:轻松搭建实时通信应用
- 2024-11-19Nacos安装资料详解:新手入门教程
- 2024-11-19Nacos安装资料:新手入门教程
- 2024-11-19升级 Gerrit 时有哪些注意事项?-icode9专业技术文章分享
- 2024-11-19pnpm是什么?-icode9专业技术文章分享