Avalonia开发实践(六)——实现Breadcrumb(面包屑)控件

引言

面包屑控件是在单窗体应用中常用的一种控件,用来指示当前页面的路由信息,以及提供导航功能。本文将完整地呈现一个面包屑控件的诞生过程。

一、面包屑控件的结构

面包屑控件由一个主体Breadcrumb和内部的BreadcrumbItem集合组成,这种关系自然而然让人联想到ItemsControl。以下是具体的控件实现:

Breadcrumb控件

Breadcrumb 控件继承自 ItemsControl,并实现了 ICommandSource 接口。它的主要功能是管理面包屑项并处理导航事件。

/// <summary>
/// Represents a breadcrumb control that displays a hierarchical navigation path.
/// </summary>
public class Breadcrumb : ItemsControl, ICommandSource
{
    #region 指定子项容器
    /// <inheritdoc/>
    protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
    {
        return new BreadcrumbItem();
    }

    /// <inheritdoc/>
    protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
    {
        return NeedsContainer<BreadcrumbItem>(item, out recycleKey);
    }
    #endregion

    #region 样式化属性
    /// <summary>
    /// Gets or sets the separator used between breadcrumb items.
    /// </summary>
    public static readonly StyledProperty<object> SeparatorProperty =
        AvaloniaProperty.Register<Breadcrumb, object>(nameof(Separator), "/");

    /// <summary>
    /// Gets or sets the separator used between breadcrumb items.
    /// </summary>
    public object Separator
    {
        get => GetValue(SeparatorProperty);
        set => SetValue(SeparatorProperty, value);
    }
    #endregion

    #region 路由事件
    /// <summary>
    /// Defines the <see cref="NavigateTo"/> event.
    /// </summary>
    public static readonly RoutedEvent<CustomRoutedEventArgs<string>> NavigateToEvent =
        RoutedEvent.Register<Breadcrumb, CustomRoutedEventArgs<string>>(nameof(NavigateTo), RoutingStrategies.Bubble);

    /// <summary>
    /// Occurs when navigation is requested.
    /// </summary>
    public event EventHandler<CustomRoutedEventArgs<string>> NavigateTo
    {
        add => AddHandler(NavigateToEvent, value);
        remove => RemoveHandler(NavigateToEvent, value);
    }
    #endregion

    #region 命令实现
    private bool _commandCanExecute = true;

    /// <summary>
    /// Gets or sets the command to be executed when a breadcrumb item is clicked.
    /// </summary>
    public ICommand Command
    {
        get { return GetValue(CommandProperty); }
        set { SetValue(CommandProperty, value); }
    }

    /// <summary>
    /// Defines the <see cref="Command"/> property.
    /// </summary>
    public static readonly StyledProperty<ICommand> CommandProperty = AvaloniaProperty.Register<Breadcrumb, ICommand>(nameof(Command));

    /// <summary>
    /// Gets or sets the parameter to be passed to the <see cref="Command"/>.
    /// </summary>
    public object? CommandParameter
    {
        get { return GetValue(CommandParameterProperty); }
        set { SetValue(CommandParameterProperty, value); }
    }

    /// <summary>
    /// Defines the <see cref="CommandParameter"/> property.
    /// </summary>
    public static readonly StyledProperty<object?> CommandParameterProperty = AvaloniaProperty.Register<Breadcrumb, object?>(nameof(CommandParameter));

    /// <summary>
    /// Handles the CanExecuteChanged event of the command.
    /// </summary>
    /// <param name="sender">The source of the event.</param>
    /// <param name="e">An EventArgs that contains the event data.</param>
    public void CanExecuteChanged(object sender, EventArgs e)
    {
        (var command, var parameter) = (Command, CommandParameter);
        CanExecuteChanged(command, parameter);
    }

    /// <summary>
    /// Updates the command execution state.
    /// </summary>
    /// <param name="command">The command to check.</param>
    /// <param name="parameter">The command parameter.</param>
    private void CanExecuteChanged(ICommand? command, object? parameter)
    {
        if (!((ILogical)this).IsAttachedToLogicalTree)
        {
            return;
        }

        var canExecute = command is null || command.CanExecute(parameter);

        if (canExecute != _commandCanExecute)
        {
            _commandCanExecute = canExecute;
            UpdateIsEffectivelyEnabled();
        }
    }
    #endregion

    #region 构造函数
    /// <summary>
    /// Initializes a new instance of the <see cref="Breadcrumb"/> class.
    /// </summary>
    public Breadcrumb()
    {
        this.AddHandler(BreadcrumbItem.NavigateToEvent, (s, e) =>
        {
            var arg = new CustomRoutedEventArgs<string>(NavigateToEvent)
            {
                Data = e.Data
            };
            RaiseEvent(arg);

            (var command, var parameter) = (Command, CommandParameter ?? e.Data);
            if (!arg.Handled && command is not null && command.CanExecute(parameter))
            {
                command.Execute(parameter);
                arg.Handled = true;
            }
        });
    }
    #endregion
}

BreadcrumbItem 控件

BreadcrumbItem 控件表示单个面包屑项,继承自 HeaderedContentControl。它包含一个 To 属性,用于指定导航目标,并定义了 NavigateTo 事件。

/// <summary>
/// Represents an individual item within a <see cref="Breadcrumb"/> control.
/// </summary>
public class BreadcrumbItem : HeaderedContentControl, ICommandSource
{
    #region 样式化属性
    /// <summary>
    /// Defines the <see cref="To"/> property.
    /// </summary>
    public static readonly StyledProperty<string> ToProperty =
        AvaloniaProperty.Register<BreadcrumbItem, string>(nameof(To));

    /// <summary>
    /// Gets or sets the target location or identifier for this breadcrumb item.
    /// </summary>
    public string To
    {
        get => GetValue(ToProperty);
        set => SetValue(ToProperty, value);
    }
    #endregion

    #region 路由事件
    /// <summary>
    /// Defines the NavigateToEvent.
    /// </summary>
    public static readonly RoutedEvent<CustomRoutedEventArgs<string>> NavigateToEvent =
        RoutedEvent.Register<BreadcrumbItem, CustomRoutedEventArgs<string>>(nameof(NavigateTo), RoutingStrategies.Bubble);

    /// <summary>
    /// Event handler for the NavigateTo event.
    /// </summary>
    public event EventHandler<CustomRoutedEventArgs<string>> NavigateTo
    {
        add => AddHandler(NavigateToEvent, value);
        remove => RemoveHandler(NavigateToEvent, value);
    }
    #endregion

    #region 命令实现
    private bool _commandCanExecute = true;

    /// <summary>
    /// Gets or Sets the <see cref="Command"/>
    /// </summary>
    public ICommand Command
    {
        get { return GetValue(CommandProperty); }
        set { SetValue(CommandProperty, value); }
    }

    /// <summary>
    /// Defines the <see cref="Command"/> property.
    /// </summary>
    public static readonly StyledProperty<ICommand> CommandProperty = AvaloniaProperty.Register<BreadcrumbItem, ICommand>(nameof(Command));

    /// <summary>
    /// Gets or Sets the <see cref="CommandParameter"/>
    /// </summary>
    public object? CommandParameter
    {
        get { return GetValue(CommandParameterProperty); }
        set { SetValue(CommandParameterProperty, value); }
    }

    /// <summary>
    /// Gets or sets a parameter to be passed to the <see cref="Command"/>
    /// </summary>
    public static readonly StyledProperty<object?> CommandParameterProperty = AvaloniaProperty.Register<BreadcrumbItem, object?>(nameof(CommandParameter));

    /// <inheritdoc/>
    public void CanExecuteChanged(object sender, EventArgs e)
    {
        (var command, var parameter) = (Command, CommandParameter);
        CanExecuteChanged(command, parameter);
    }

    private void CanExecuteChanged(ICommand? command, object? parameter)
    {
        if (!((ILogical)this).IsAttachedToLogicalTree)
        {
            return;
        }

        var canExecute = command is null || command.CanExecute(parameter);

        if (canExecute != _commandCanExecute)
        {
            _commandCanExecute = canExecute;
            UpdateIsEffectivelyEnabled();
        }
    }
    #endregion

    #region 默认实现
    /// <inheritdoc/>
    protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
    {
        base.OnApplyTemplate(e);
        var breadcrumb = this.FindAncestorOfType<Breadcrumb>();
        if (breadcrumb != null && breadcrumb.DisplayMemberBinding != null)
        {
            this[!ContentProperty] = breadcrumb.DisplayMemberBinding;
        }

        var button = e.NameScope.Find("PART_NavButton") as Button;
        if (button != null)
        {
            button.Click += NavButton_Click;
        }
    }

    private void NavButton_Click(object? sender, RoutedEventArgs e)
    {
        var arg = new CustomRoutedEventArgs<string>(NavigateToEvent)
        {
            Data = To
        };
        RaiseEvent(arg);

        (var command, var parameter) = (Command, CommandParameter ?? arg.Data);
        if (!arg.Handled && command is not null && command.CanExecute(parameter))
        {
            command.Execute(parameter);
            arg.Handled = true;
        }
    }
    #endregion
}

以上代码中用到了带自定义参数的RoutedEventArgs对象,实现如下:

/// <summary>
/// 通用的带自定义参数的路由事件参数对象
/// </summary>
/// <typeparam name="T"></typeparam>
public class CustomRoutedEventArgs<T> : RoutedEventArgs
{
    /// <summary>
    /// 事件参数
    /// </summary>
    public T Data { get; set; }

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="routedEvent">路由事件</param>
    /// <param name="source">事件发起者</param>
    public CustomRoutedEventArgs(RoutedEvent routedEvent, object source) : base(routedEvent, source)
    {
    }

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="routedEvent">路由事件</param>
    public CustomRoutedEventArgs(RoutedEvent routedEvent) : base(routedEvent)
    {
    }
}

二、面包屑控件的模板及样式

在Avalonia中,使用ControlTheme定义控件的模板,在Styles中定义控件的样式。

控件模板

<ControlTheme x:Key="{x:Type local:Breadcrumb}" TargetType="local:Breadcrumb">
	<Setter Property="Background" Value="Transparent"/>
	<Setter Property="BorderBrush" Value="Transparent"/>
	<Setter Property="BorderThickness" Value="0"/>
	<Setter Property="Padding" Value="0"/>
	<Setter Property="ItemsPanel">
		<Setter.Value>
			<ItemsPanelTemplate>
				<StackPanel Orientation="Horizontal"/>
			</ItemsPanelTemplate>
		</Setter.Value>
	</Setter>
	<Setter Property="Template">
		<ControlTemplate>
			<Border Background="{TemplateBinding Background}"
                    BorderBrush="{TemplateBinding BorderBrush}"
                    BorderThickness="{TemplateBinding BorderThickness}">
				<ItemsPresenter Margin="{TemplateBinding Padding}" ItemsPanel="{TemplateBinding ItemsPanel}"/>
			</Border>
		</ControlTemplate>
	</Setter>
</ControlTheme>

<ControlTheme x:Key="{x:Type local:BreadcrumbItem}" TargetType="local:BreadcrumbItem">
	<Setter Property="Padding" Value="5,0"/>
	<Setter Property="Foreground" Value="{Binding RelativeSource={RelativeSource AncestorType=local:Breadcrumb}, Path=Foreground}"/>
	<Setter Property="Header" Value="{Binding RelativeSource={RelativeSource AncestorType=local:Breadcrumb}, Path=Separator}"/>
	<Setter Property="Template">
		<ControlTemplate>
			<StackPanel Orientation="Horizontal">
				<ContentPresenter x:Name="PART_Header"
                                Content="{TemplateBinding Header}"
                                Margin="5,0"
                                VerticalAlignment="Center"
								IsVisible="{TemplateBinding Header,Converter={x:Static ObjectConverters.IsNotNull}}"/>

				<ContentPresenter x:Name="PART_Content"
									Content="{TemplateBinding Content}"
									Margin="{TemplateBinding Padding}"
									IsVisible="{TemplateBinding To,Converter={x:Static StringConverters.IsNullOrEmpty}}"
									VerticalAlignment="Center"/>
				<Button x:Name="PART_NavButton"
                        Classes="text"
                        Foreground="{TemplateBinding Foreground}"
						Content="{TemplateBinding Content}"
						Padding="{TemplateBinding Padding}"
                        IsVisible="{TemplateBinding To,Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
                        VerticalAlignment="Center"/>
			</StackPanel>
		</ControlTemplate>
	</Setter>
</ControlTheme>

控件样式

<!-- 第一个元素不显示分隔符 -->
<Style Selector="local|BreadcrumbItem:nth-child(1) /template/ ContentPresenter#PART_Header">
	<Setter Property="IsVisible" Value="False"/>
</Style>

<!--文本样式的按钮-->
<Style Selector="Button.text">
	<Setter Property="BorderThickness" Value="0"/>
	<Setter Property="Background" Value="Transparent"/>
	<Setter Property="HorizontalAlignment" Value="Center"/>
	<Setter Property="VerticalAlignment" Value="Center"/>
	<Setter Property="Padding" Value="0"/>
	<Setter Property="Template">
		<ControlTemplate>
			<Border Background="{TemplateBinding Background}"
					BorderBrush="{TemplateBinding BorderBrush}"
					BorderThickness="{TemplateBinding BorderThickness}"
					CornerRadius="{TemplateBinding CornerRadius}">
				<ContentPresenter x:Name="content"
									Content="{TemplateBinding Content}"
									HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
									VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
									Margin="{TemplateBinding Padding}"/>
			</Border>
		</ControlTemplate>
	</Setter>

	<!-- PointerOver state -->
	<Style Selector="^:pointerover">
		<Setter Property="Opacity" Value="0.6"/>
	</Style>

	<!-- Pressed state -->
	<Style Selector="^:pressed">
		<Setter Property="Opacity" Value="0.8"/>
	</Style>

	<!-- Disabled state -->
	<Style Selector="^:disabled">
		<Setter Property="Opacity" Value="0.5"/>
	</Style>
</Style>

总结

通过以上的实现,就创建好了一个具备导航功能的面包屑控件,且支持数据绑定,可以用于各种场景。

上一篇:Avalonia开发实践(五)——如何用后台代码创建模板

下一篇:Avalonia开发实践(七)——实现全局的弹窗遮罩

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值