WPF自定義控件第二 - 轉盤按鈕控件

繼以前那個控件,又作了一個原理差很少的控件。這個控件主要模仿百度貼吧WP版帖子瀏覽界面左下角那個彈出的按鈕盤。但願對你們有幫助。html

這個控件和以前的也差很少,爲了避免讓你們白看,文章最後發乾貨。node

因爲這個控件和以前一篇文章介紹控件基本差很少,因此一些基本的實現點再也不贅述,文本將主要介紹與這個控件功能密切相關的部分。開始正題。git

劇透一下,博主後來又在WinRT(真不知道該叫什麼好了,如今該叫它UWP嗎?)中把這個控件實現了一遍,提及來WinRT與WPF仍是有很大不一樣的,這個控件的實現方式也有不少不一樣之處。後續的文章將會有介紹。github

按慣例先上最終效果圖:c#

彈出的子菜單能夠點擊,用於自定義須要點擊子菜單實現的功能:ide

 

首先仍是先來展現一下控件模板的基本結構:函數

基本上分爲四部分:定義狀態,定義中間的大按鈕,圓形透明背景,以及顯示一圈小按鈕的Panel。佈局

大按鈕和圓形透明背景很簡單:學習

<Border Panel.ZIndex="999" x:Name="PART_CenterBtn" VerticalAlignment="Center" HorizontalAlignment="Center"
            Width="50" Height="50" CornerRadius="25" BorderThickness="0" BorderBrush="Blue" Background="CadetBlue">
    <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="讀書"></TextBlock>
</Border>

<Ellipse Width="{TemplateBinding Width}" Height="{TemplateBinding Height}" Panel.ZIndex="-1" Fill="#66559977"></Ellipse>

注意:當前圓形背景不能按設置的角度變成扇形,須要這個功能的童鞋能夠自行作一個能夠綁定到角度的扇形控件。動畫

最值得關注就是定義的幾個狀態,子菜單正是根據不一樣的狀態來在收縮和展開模型來回切換。狀態定義以下:

<VisualStateManager.VisualStateGroups>
    <VisualStateGroup x:Name="CommonStates">
        <VisualState x:Name="Initial">
            <Storyboard >
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter"
                                               Storyboard.TargetProperty="Status">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <circleMenu:CircleMenuStatus>Initial</circleMenu:CircleMenuStatus>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
        </VisualState>
        <VisualState x:Name="Collapsed">
            <Storyboard >
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter"
                                               Storyboard.TargetProperty="Status">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <circleMenu:CircleMenuStatus>Collapsed</circleMenu:CircleMenuStatus>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
        </VisualState>
        <VisualState x:Name="Expanded">
            <Storyboard >
                <ObjectAnimationUsingKeyFrames Storyboard.TargetName="PART_PanelPresenter" 
                                               Storyboard.TargetProperty="Status">
                    <DiscreteObjectKeyFrame KeyTime="0">
                        <DiscreteObjectKeyFrame.Value>
                            <circleMenu:CircleMenuStatus>Expanded</circleMenu:CircleMenuStatus>
                        </DiscreteObjectKeyFrame.Value>
                    </DiscreteObjectKeyFrame>
                </ObjectAnimationUsingKeyFrames>
            </Storyboard>
        </VisualState>
    </VisualStateGroup>
</VisualStateManager.VisualStateGroups>

不一樣狀態的切換經過Storyboard中的ObjectAnimationUsingKeyFrames控制PART_Presenter的Status。這個PART_Presenter是咱們自定義的繼承自ItemPresenter的一個類型的對象。控件以這個自定義的Presenter做爲橋樑,外部經過VisualStateManager更改其Status這個依賴屬性,而內部自定義的Panel能夠綁定到這個Status屬性,從而根據當前的狀態來對其中的元素進行佈局。

先來看看這個自定義的ItemPresenter:

public class CircleMenuItemsPresenter:ItemsPresenter
{
    public static readonly DependencyProperty StatusProperty = DependencyProperty.Register(
        "Status", typeof (CircleMenuStatus), typeof (CircleMenuItemsPresenter), new PropertyMetadata(default(CircleMenuStatus)));

    public CircleMenuStatus Status
    {
        get { return (CircleMenuStatus) GetValue(StatusProperty); }
        set { SetValue(StatusProperty, value); }
    }

    public static readonly DependencyProperty AngleProperty = DependencyProperty.Register(
        "Angle", typeof(Double), typeof(CircleMenuItemsPresenter), new PropertyMetadata(360d));

    public double Angle
    {
        get { return (Double)GetValue(AngleProperty); }
        set { SetValue(AngleProperty, value); }
    }
}

很簡單就是添加了做爲Control和Panel橋樑的幾個依賴屬性。(最根本的緣由仍是自定義的Panel不能直接綁定到Control的依賴屬性,最多隻能綁定到其父級ItemPresenter)

在WinRT中ItemPresenter變成了密封類,咱們無法像上面那個自定義一個ItemPresenter供Panel綁定。因此實現方式有了很大變化。之後的文章會細說

接着是ItemPresenter和Panel的聲明:

<circleMenu:CircleMenuItemsPresenter x:Name="PART_PanelPresenter" Status="Initial" Angle="{TemplateBinding Angle}" />

<Setter Property="ItemsPanel">
    <Setter.Value>
        <ItemsPanelTemplate>
            <circleMenu:CircleMenuPanel x:Name="CircleMenuPanel" AnimationDuration="{StaticResource CircleDuration}" 
                                                                 AnimationDurationStep="0.2"
                                                                 Radius="100" 
                                        Angle="{Binding Angle, RelativeSource={RelativeSource FindAncestor, AncestorType=circleMenu:CircleMenuItemsPresenter}}" 
                                        PanelStatus="{Binding Status, RelativeSource={RelativeSource FindAncestor, AncestorType=circleMenu:CircleMenuItemsPresenter } }" />
        </ItemsPanelTemplate>
    </Setter.Value>
</Setter>

能夠看到,若是想讓這個自定的Panel綁定到外部(控件級)的依賴屬性就須要經過ItemPresenter中轉一下。

接着是整個控件最核心的一部分CircleMenuPanel這個自定義面板的實現,這個文件比較長,分段來看。

首先是一些依賴屬性,在上面的XAML它們的身影也出現過一次。

public static readonly DependencyProperty AnimationDurationProperty = DependencyProperty.Register(
            "AnimationDuration", typeof(Duration), typeof(CircleMenuPanel), new PropertyMetadata(default(Duration)));

public Duration AnimationDuration
{
    get { return (Duration)GetValue(AnimationDurationProperty); }
    set { SetValue(AnimationDurationProperty, value); }
}

public static readonly DependencyProperty AnimationDurationStepProperty = DependencyProperty.Register(
    "AnimationDurationStep", typeof(double), typeof(CircleMenuPanel), new PropertyMetadata(0.3d));

public double AnimationDurationStep
{
    get { return (double)GetValue(AnimationDurationStepProperty); }
    set { SetValue(AnimationDurationStepProperty, value); }
}


public static readonly DependencyProperty RadiusProperty = DependencyProperty.Register(
    "Radius", typeof(Double), typeof(CircleMenuPanel), new PropertyMetadata(50d));

public double Radius
{
    get { return (Double)GetValue(RadiusProperty); }
    set { SetValue(RadiusProperty, value); }
}

public static readonly DependencyProperty AngleProperty = DependencyProperty.Register(
    "Angle", typeof(double), typeof(CircleMenuPanel), new PropertyMetadata(360d));

public double Angle
{
    get { return (double)GetValue(AngleProperty); }
    set { SetValue(AngleProperty, value); }
}

public static readonly DependencyProperty PanelStatusProperty = DependencyProperty.Register(
    "PanelStatus", typeof(CircleMenuStatus), typeof(CircleMenuPanel), new PropertyMetadata(CircleMenuStatus.Initial, ReRender));

public CircleMenuStatus PanelStatus
{
    get { return (CircleMenuStatus)GetValue(PanelStatusProperty); }
    set { SetValue(PanelStatusProperty, value); }
}

private static void ReRender(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var circelPanel = (CircleMenuPanel)d;
    circelPanel.InvalidateArrange();
}

值得注意的是,在PanelStatus變化時觸發了一個回調函數用來實如今面板(控件)狀態變化時的重繪。

接着就是和佈局相關的兩個方法:

protected override Size MeasureOverride(Size availableSize)
{
    var s = base.MeasureOverride(availableSize);
    foreach (UIElement element in this.Children)
    {
        element.Measure(availableSize);
    }
    return availableSize;
}

//http://www.cnblogs.com/mantgh/p/4161142.html
protected override Size ArrangeOverride(Size finalSize)
{
    var cutNum = (int)Angle == 360 ? this.Children.Count : (this.Children.Count - 1);
    var degreesOffset = Angle / cutNum;
    var i = 0;
    foreach (ContentPresenter element in Children)
    {
        var elementRadius = element.DesiredSize.Width / 2.0;
        var elementCenterX = elementRadius;
        var elementCenterY = elementRadius;

        var panelCenterX = Radius - elementRadius;
        var panelCenterY = Radius - elementRadius;

        var degreesAngle = degreesOffset * i;
        var radianAngle = (Math.PI * degreesAngle) / 180.0;
        var x = this.Radius * Math.Sin(radianAngle);
        var y = -this.Radius * Math.Cos(radianAngle);
        var destX = x + finalSize.Width / 2 - elementCenterX;
        var destY = y + finalSize.Height / 2 - elementCenterY;

        switch (PanelStatus)
        {
            case CircleMenuStatus.Initial:
                ArrangeInitialElement(element, panelCenterX, panelCenterY);
                break;
            case CircleMenuStatus.Collapsed:
                ArrangeCollapseElement(i, element, panelCenterX, panelCenterY, elementCenterX, elementCenterY, destX, destY);
                break;
            case CircleMenuStatus.Expanded:
                ArrangeExpandElement(i, element, panelCenterX, panelCenterY, elementCenterX, elementCenterY, destX, destY);
                break;
        }

        ++i;
    }
    return finalSize;
}

固然重點是在ArrangeOverride方法中,針對每一個元素的操做先是通過一些列計算獲得分佈在圓周上的位置,而後根據面板狀態分別調用3個方法進行實際位置佈局。若是是初始狀態,只須要放置於中點就能夠了。若是是Collapsed,則將子元素由圓周移動回中心。反之若是是Expanded則將小球由中心逐漸移動到圓周。

三個實際佈局方法見下:

private void ArrangeExpandElement(int idx, ContentPresenter element,
    double panelCenterX, double panelCenterY,
    double elementCenterX, double elementCenterY,
    double destX, double destY)
{
    element.Arrange(new Rect(panelCenterX, panelCenterY, element.DesiredSize.Width, element.DesiredSize.Height));

    var transGroup = element.RenderTransform as TransformGroup;
    Transform translateTransform, rotateTransform;
    if (transGroup == null)
    {
        element.RenderTransform = transGroup = new TransformGroup();
        translateTransform = new TranslateTransform();
        rotateTransform = new RotateTransform() { CenterX = elementCenterX, CenterY = elementCenterY };

        transGroup.Children.Add(translateTransform);
        transGroup.Children.Add(rotateTransform);
    }
    else
    {
        translateTransform = transGroup.Children[0] as TranslateTransform;
        rotateTransform = transGroup.Children[1] as RotateTransform;
    }
    element.RenderTransformOrigin = new Point(0.5, 0.5);

    //if (i != 0) continue;

    var aniDuration = AnimationDuration + TimeSpan.FromSeconds(AnimationDurationStep * idx);
    translateTransform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, destX - panelCenterX, aniDuration));
    translateTransform.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, destY - panelCenterY, aniDuration));

    rotateTransform.BeginAnimation(RotateTransform.CenterXProperty, new DoubleAnimation(0, destX - panelCenterX, aniDuration));
    rotateTransform.BeginAnimation(RotateTransform.CenterYProperty, new DoubleAnimation(0, destY - panelCenterY, aniDuration));
    rotateTransform.BeginAnimation(RotateTransform.AngleProperty, new DoubleAnimation(0, 720, aniDuration));

    element.BeginAnimation(OpacityProperty, new DoubleAnimation(0.2, 1, aniDuration));
}

private void ArrangeInitialElement(ContentPresenter element, double panelCenterX, double panelCenterY)
{
    element.Arrange(new Rect(panelCenterX, panelCenterY, element.DesiredSize.Width, element.DesiredSize.Height));
}

private void ArrangeCollapseElement(int idx, ContentPresenter element,
                double panelCenterX, double panelCenterY,
                double elementCenterX, double elementCenterY,
                double destX, double destY)
{
    element.Arrange(new Rect(destX, destY, element.DesiredSize.Width, element.DesiredSize.Height));

    var transGroup = element.RenderTransform as TransformGroup;
    Transform translateTransform, rotateTransform;
    if (transGroup == null)
    {
        element.RenderTransform = transGroup = new TransformGroup();
        translateTransform = new TranslateTransform();
        rotateTransform = new RotateTransform() { CenterX = elementCenterX, CenterY = elementCenterY };

        transGroup.Children.Add(translateTransform);
        transGroup.Children.Add(rotateTransform);
    }
    else
    {
        translateTransform = transGroup.Children[0] as TranslateTransform;
        rotateTransform = transGroup.Children[1] as RotateTransform;
    }
    element.RenderTransformOrigin = new Point(0.5, 0.5);

    //if (i != 0) continue;

    var aniDuration = AnimationDuration + TimeSpan.FromSeconds(AnimationDurationStep * idx);
    translateTransform.BeginAnimation(TranslateTransform.XProperty, new DoubleAnimation(0, panelCenterX - destX, aniDuration));
    translateTransform.BeginAnimation(TranslateTransform.YProperty, new DoubleAnimation(0, panelCenterY - destY, aniDuration));

    rotateTransform.BeginAnimation(RotateTransform.CenterXProperty, new DoubleAnimation(0, panelCenterX - destX, aniDuration));
    rotateTransform.BeginAnimation(RotateTransform.CenterYProperty, new DoubleAnimation(0, panelCenterY - destY, aniDuration));
    rotateTransform.BeginAnimation(RotateTransform.AngleProperty, new DoubleAnimation(0, -720, aniDuration));

    element.BeginAnimation(OpacityProperty, new DoubleAnimation(1, 0.2, aniDuration));
}

透明動畫是直接給子元素的Opacity屬性施加了動畫效果,而移動和旋轉先組合爲一個TransformGroup而後應用給子元素的RenderTransform。代碼很容易懂,實現的時候注意下TranslateTransform的起至點座標的計算和RotateTransform變化的旋轉中心點的便可。特別是這個旋轉中心點,其隨着子元素「移動」過程也在不停的變化,從而使子元素老是相對於「當前」的中心在旋轉。

到這裏剩下的都比較簡單了:

控件的代碼:

    [TemplatePart(Name = PartCenterBtn)]
    [TemplatePart(Name = PartContainer)]
    [TemplatePart(Name = PartPanelPresenter)]
    [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateInitial)]
    [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateExpanded)]
    [TemplateVisualState(GroupName = "CommonStates", Name = VisualStateCollapsed)]
    public class CircleMenuControl : ItemsControl
    {
        private const string PartCenterBtn = "PART_CenterBtn";
        private const string PartContainer = "PART_Container";
        private const string PartPanelPresenter = "PART_PanelPresenter";
        public const string VisualStateInitial = "Initial";
        public const string VisualStateExpanded = "Expanded";
        public const string VisualStateCollapsed = "Collapsed";

        static CircleMenuControl()
        {
            DefaultStyleKeyProperty.OverrideMetadata(typeof(CircleMenuControl), new FrameworkPropertyMetadata(typeof(CircleMenuControl)));
        }

        #region dependency property

        public static readonly DependencyProperty AngleProperty = DependencyProperty.Register(
            "Angle", typeof(double), typeof(CircleMenuControl), new PropertyMetadata(360d));

        public double Angle
        {
            get { return (double)GetValue(AngleProperty); }
            set { SetValue(AngleProperty, value); }
        }

        #endregion

        private Border _centerBtn;
        private Grid _container;
        private CircleMenuPanel _circleMenuPanel;
        private CircleMenuItemsPresenter _circleMenuItemsPresenter;

        public override void OnApplyTemplate()
        {
            if (_centerBtn != null)
            {
                _centerBtn.MouseLeftButtonUp -= centerBtn_Click;
            }

            base.OnApplyTemplate();

            _centerBtn = GetTemplateChild(PartCenterBtn) as Border;
            _container = GetTemplateChild(PartContainer) as Grid;
            _circleMenuItemsPresenter = GetTemplateChild(PartPanelPresenter) as CircleMenuItemsPresenter;

            if (_centerBtn != null)
            {
                _centerBtn.MouseLeftButtonUp += centerBtn_Click;
            }
        }

        private void centerBtn_Click(object sender, RoutedEventArgs e)
        {
            //第一個參數是<VisualStateManager>所在元素的父元素,本控件中爲Grid的父級,即控件自己
            switch (_circleMenuItemsPresenter.Status)
            {
                case CircleMenuStatus.Expanded:
                    VisualStateManager.GoToState(this, VisualStateCollapsed, false);
                    break;
                case CircleMenuStatus.Initial:
                case CircleMenuStatus.Collapsed:
                    VisualStateManager.GoToState(this, VisualStateExpanded, false);
                    break;
            }

            //若是隻是在控件內部更改Panel狀態能夠直接設置ItemPresenter的Status
            //使用VisualStateManager是爲了能夠在外部經過更改狀態更新面板
        }

        #region route event

        //inner menu click
        public static readonly RoutedEvent SubMenuClickEvent =
            ButtonBase.ClickEvent.AddOwner(typeof (CircleMenuControl));

        public event RoutedEventHandler SubMenuClick
        {
            add { AddHandler(ButtonBase.ClickEvent, value, false); }
            remove { RemoveHandler(ButtonBase.ClickEvent, value); }
        }

        #endregion

    }

能夠看到仍然是從ItemsControl集成來的控件。

這幾行聲明模板支持狀態的代碼能夠告訴自定義控件模板的用戶能夠在模板中定義哪幾種VisualState:

[TemplateVisualState(GroupName = "CommonStates", Name = VisualStateInitial)]
[TemplateVisualState(GroupName = "CommonStates", Name = VisualStateExpanded)]
[TemplateVisualState(GroupName = "CommonStates", Name = VisualStateCollapsed)]

在中央按鈕被點擊的時候調用VisualStateManger.GoToState來切換控件狀態。

private void centerBtn_Click(object sender, RoutedEventArgs e)
{
    //第一個參數是<VisualStateManager>所在元素的父元素,本控件中爲Grid的父級,即控件自己
    switch (_circleMenuItemsPresenter.Status)
    {
        case CircleMenuStatus.Expanded:
            VisualStateManager.GoToState(this, VisualStateCollapsed, false);
            break;
        case CircleMenuStatus.Initial:
        case CircleMenuStatus.Collapsed:
            VisualStateManager.GoToState(this, VisualStateExpanded, false);
            break;
    }
}

而子元素點擊事件的發佈和以前的控件處理方式差很少。

這裏在控件中定義一個路由事件,處理子控件中沒有被處理的Button.Click事件(這裏選用了簡單的實現方式限制子元素爲Button):

#region route event

//inner menu click
public static readonly RoutedEvent SubMenuClickEvent =
    ButtonBase.ClickEvent.AddOwner(typeof (CircleMenuControl));

public event RoutedEventHandler SubMenuClick
{
    add { AddHandler(ButtonBase.ClickEvent, value, false); }
    remove { RemoveHandler(ButtonBase.ClickEvent, value); }
}

#endregion

從控件使用的代碼能夠看到怎麼訂閱這個事件:

<circleMenu:CircleMenuControl ItemsSource="{Binding SubMenuItems}" Width="200" Height="200"
                              BorderThickness="2" BorderBrush="Black">
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="SubMenuClick">
            <command:EventToCommand Command="{Binding NodeClickCommand, Mode=OneWay}" PassEventArgsToCommand="True" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
    <circleMenu:CircleMenuControl.ItemTemplate>
        <DataTemplate>
            <Button>
                <Button.Template>
                    <ControlTemplate TargetType="Button">
                        <Border CornerRadius="15" Background="Coral" Width="30" Height="30" >
                            <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" />
                        </Border>
                    </ControlTemplate>
                </Button.Template>
                <Button.Content>
                        <TextBlock Text="{Binding Title}"></TextBlock>
                </Button.Content>
            </Button>
            
        </DataTemplate>
    </circleMenu:CircleMenuControl.ItemTemplate>
</circleMenu:CircleMenuControl>

Item模板是一個自定義模板的Button,其未處理的事件會被向上傳遞觸發控件的SubMenuClick事件。訂閱事件仍是藉助MVVM Light中的EventToCommand這個方便的標籤。事件處理Command:

private RelayCommand<RoutedEventArgs> _nodeClickCommand;

public RelayCommand<RoutedEventArgs> NodeClickCommand
{
    get
    {
        return _nodeClickCommand
            ?? (_nodeClickCommand = new RelayCommand<RoutedEventArgs>(
                                  p =>
                                  {
                                      var dataItem = ((FrameworkElement)p.OriginalSource).DataContext;
                                      MessageBox.Show(((CircleMenuItem)dataItem).Id.ToString());

                                      var circleCtrl = (CircleMenuControl)p.Source;
                                      var suc = VisualStateManager.GoToState(circleCtrl, CircleMenuControl.VisualStateCollapsed, false);
                                      var bb = 1;
                                  }));
    }
}

最後爲了完整性,把子元素用到的實體和綁定Items列表的代碼也列到下面。這些和以前控件所介紹的基本一致。

public class CircleMenuItem
{
    public CircleMenuItem()
    {
    }

    public CircleMenuItem(int id, string title,double offsetRate)
    {
        Id = id;
        Title = title;
    }

    public int Id { get; set; }

    public string Title { get; set; }
}
//ViewModel
_dataService.GetData(
    (item, error) =>
    {
        SubMenuItems = new ObservableCollection<CircleMenuItem>(
            new List<CircleMenuItem>()
            {
                new CircleMenuItem() {Id = 1, Title = "衣"},
                new CircleMenuItem() {Id = 2, Title = "帶"},
                new CircleMenuItem() {Id = 3, Title = "漸"},
                new CircleMenuItem() {Id = 4, Title = "寬"},
                new CircleMenuItem() {Id = 5, Title = "終"},
                new CircleMenuItem() {Id = 6, Title = "不"},
                new CircleMenuItem() {Id = 7, Title = "悔"},
                new CircleMenuItem() {Id = 8, Title = "爲"},
                new CircleMenuItem() {Id = 9, Title = "伊"},
                new CircleMenuItem() {Id = 10, Title = "消"},
                new CircleMenuItem() {Id = 11, Title = "得"},
                new CircleMenuItem() {Id = 12, Title = "人"},
                new CircleMenuItem() {Id = 13, Title = "憔"},
                new CircleMenuItem() {Id = 14, Title = "悴"}
            });
    });

private ObservableCollection<CircleMenuItem> _subMenuItems;

public ObservableCollection<CircleMenuItem> SubMenuItems
{
    get { return _subMenuItems; }
    set { Set(() => SubMenuItems, ref _subMenuItems, value); }
}

基本上這個控件就是這樣,你們多給意見。下面是幹活

 

其餘乾貨

在很長一段學習使用XAML系開發平臺的過程當中,逐步整理完善了一份Xmind文件,發出來供你們使用。像WPF繫結構複雜,若是忘了什麼能夠看一個這個文檔參考,能夠省很多時間。

先上幾張圖,後面有下載地址

圖1

圖2

圖3

 

下載地址

 

代碼下載

Github

 

 

版權說明:本文版權歸博客園和hystar全部,轉載請保留本文地址。文章代碼能夠在項目隨意使用,若是以文章出版物形式出現請代表來源,尤爲對於博主引用的代碼請保留其中的原出處尊重原做者勞動成果。

相關文章
相關標籤/搜索