Merge pull request #62 from irihitech/timeline

Timeline refactoring
This commit is contained in:
Zhang Dian
2024-01-08 23:40:45 +08:00
committed by GitHub
11 changed files with 738 additions and 228 deletions

View File

@@ -6,6 +6,7 @@
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -0,0 +1,32 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Ursa.Controls;
namespace Ursa.Demo.Converters;
public class TimelineIconConverter: IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is TimelineItemType t)
{
return t switch
{
TimelineItemType.Success => Brushes.Green,
TimelineItemType.Ongoing => Brushes.Blue,
TimelineItemType.Error => Brushes.Red,
_ => Brushes.Gray
};
}
return AvaloniaProperty.UnsetValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View File

@@ -144,27 +144,29 @@
IPAddress="{Binding Address}" /> IPAddress="{Binding Address}" />
<u:IPv4Box HorizontalAlignment="Stretch" IsEnabled="False" /> <u:IPv4Box HorizontalAlignment="Stretch" IsEnabled="False" />
</StackPanel> </StackPanel>
<u:Timeline Grid.Column="1"> <u:Timeline Grid.Column="1" HorizontalAlignment="Left" Mode="Alternate">
<u:TimelineItem <u:TimelineItem
Content="ToDo" Content="Step 1"
ItemType="Default" Header="ToDo"
Time="2022-01-01" /> Type="Default"
Time="2023-01-14 09:24:05"/>
<u:TimelineItem <u:TimelineItem
Content="Start" Content="Step 2"
ItemType="Ongoing" Header="Start"
Time="2022-01-02" /> Position="Right"
Type="Ongoing"
Time="2024-01-04 22:32:58"/>
<u:TimelineItem <u:TimelineItem
Content="In between" Content="Step 3"
ItemType="Success" Header="In Between"
Time="2022-01-03" /> Type="Warning"
Time="2024-01-05 00:08:29"/>
<u:TimelineItem <u:TimelineItem
Content="In between" Content="Step 4"
ItemType="Warning" Header="Finished"
Time="2022-01-04" /> Position="Right"
<u:TimelineItem Type="Success"
Content="Finished" Time="2024-01-05 00:27:44"/>
ItemType="Error"
Time="2022-01-05" />
</u:Timeline> </u:Timeline>
<StackPanel Grid.Column="2" Spacing="20"> <StackPanel Grid.Column="2" Spacing="20">
<u:ButtonGroup Classes="Primary Solid" ItemsSource="{Binding ButtonGroupItems}" /> <u:ButtonGroup Classes="Primary Solid" ItemsSource="{Binding ButtonGroupItems}" />

View File

@@ -4,55 +4,84 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:selectors="clr-namespace:Ursa.Demo.TemplateSelectors"
xmlns:u="https://irihi.tech/ursa" xmlns:u="https://irihi.tech/ursa"
xmlns:viewModels="clr-namespace:Ursa.Demo.ViewModels" xmlns:viewModels="clr-namespace:Ursa.Demo.ViewModels"
d:DesignHeight="450" d:DesignHeight="450"
d:DesignWidth="800" d:DesignWidth="800"
x:CompileBindings="False" x:CompileBindings="True"
x:DataType="viewModels:TimelineDemoViewModel" x:DataType="viewModels:TimelineDemoViewModel"
mc:Ignorable="d"> mc:Ignorable="d">
<UserControl.Resources> <UserControl.Resources>
<u:TimelineFormatConverter x:Key="FormatConverter" /> <ResourceDictionary>
<selectors:TimelineIconTemplateSelector x:Key="IconSelector" >
<SolidColorBrush x:Key="Default" Color="{DynamicResource SemiGray6Color}"></SolidColorBrush>
<SolidColorBrush x:Key="Ongoing" Color="{DynamicResource SemiBlue6Color}"></SolidColorBrush>
<SolidColorBrush x:Key="Success" Color="{DynamicResource SemiGreen6Color}"></SolidColorBrush>
<SolidColorBrush x:Key="Warning" Color="{DynamicResource SemiOrange6Color}"></SolidColorBrush>
<SolidColorBrush x:Key="Error" Color="{DynamicResource SemiRed6Color}"></SolidColorBrush>
</selectors:TimelineIconTemplateSelector>
</ResourceDictionary>
</UserControl.Resources> </UserControl.Resources>
<StackPanel> <WrapPanel>
<u:Timeline> <u:Timeline
<u:TimelineItem Mode="Alternate"
Content="Start" HorizontalAlignment="Left"
ItemType="Warning" ContentMemberBinding="{ReflectionBinding Description}"
Time="2022-01-01" /> HeaderMemberBinding="{ReflectionBinding Header}"
<u:TimelineItem IconMemberBinding="{ReflectionBinding ItemType}"
Content="In between" IconTemplate="{StaticResource IconSelector}"
ItemType="Ongoing" ItemsSource="{Binding Items}"
Time="2022-01-02" /> TimeMemberBinding="{ReflectionBinding Time}" >
<u:TimelineItem
Content="Finished"
ItemType="Error"
Time="2022-01-03" />
<u:TimelineItem
Content="Finished"
IconForeground="Yellow"
ItemType="Default"
Time="2022-01-03" />
</u:Timeline> </u:Timeline>
<u:Timeline HorizontalAlignment="Left" ItemsSource="{Binding Items}"> <u:Timeline
<u:Timeline.ItemTemplate> Mode="Left"
<DataTemplate x:DataType="viewModels:TimelineItemViewModel"> HorizontalAlignment="Left"
<u:TimelineItem ContentMemberBinding="{ReflectionBinding Description}"
Content="{Binding Content}" HeaderMemberBinding="{ReflectionBinding Header}"
ItemType="{Binding ItemType}" IconMemberBinding="{ReflectionBinding ItemType}"
Time="{Binding Time}" IconTemplate="{StaticResource IconSelector}"
TimeFormat="{Binding TimeFormat}"> ItemsSource="{Binding Items}"
<u:TimelineItem.ContentTemplate> TimeMemberBinding="{ReflectionBinding Time}" >
<DataTemplate>
<TextBlock
MaxWidth="100"
Text="{Binding}"
TextWrapping="Wrap" />
</DataTemplate>
</u:TimelineItem.ContentTemplate>
</u:TimelineItem>
</DataTemplate>
</u:Timeline.ItemTemplate>
</u:Timeline> </u:Timeline>
</StackPanel> <u:Timeline
Mode="Right"
HorizontalAlignment="Left"
ContentMemberBinding="{ReflectionBinding Description}"
HeaderMemberBinding="{ReflectionBinding Header}"
IconMemberBinding="{ReflectionBinding ItemType}"
IconTemplate="{StaticResource IconSelector}"
ItemsSource="{Binding Items}"
TimeMemberBinding="{ReflectionBinding Time}" >
</u:Timeline>
<u:Timeline HorizontalAlignment="Left" Mode="Alternate">
<u:TimelineItem
Content="Step 1"
Header="第一步"
Position="Left"
Type="Default" />
<u:TimelineItem
Content="Step 2"
Header="第二步"
Position="Right"
Type="Success" />
<u:TimelineItem
Content="Step 3"
Header="第三步"
Position="Separate"
Type="Warning" />
<u:TimelineItem
Content="Step 4"
Header="第四步"
Position="Separate"
Type="Ongoing" />
<u:TimelineItem
Content="Step 5"
Header="第五步"
Position="Separate"
TimeFormat="yyyy-MM-dd"
Type="Error" />
</u:Timeline>
</WrapPanel>
</UserControl> </UserControl>

View File

@@ -0,0 +1,34 @@
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Controls.Templates;
using Avalonia.Media;
using Ursa.Controls;
namespace Ursa.Demo.TemplateSelectors;
public class TimelineIconTemplateSelector: ResourceDictionary, IDataTemplate
{
public Control? Build(object? param)
{
if (param is TimelineItemType t)
{
string s = t.ToString();
if (ContainsKey(s))
{
object? o = this[s];
if (o is SolidColorBrush c)
{
var ellipse = new Ellipse() { Width = 12, Height = 12, Fill = c };
return ellipse;
}
}
}
return null;
}
public bool Match(object? data)
{
return data is TimelineItemType;
}
}

View File

@@ -11,41 +11,24 @@ public class TimelineDemoViewModel: ViewModelBase
new() new()
{ {
Time = DateTime.Now, Time = DateTime.Now,
TimeFormat = "yyyy-MM-dd HH:mm:ss",
Description = "Item 1", Description = "Item 1",
Content = "First", Header = "审核中",
ItemType = TimelineItemType.Success, ItemType = TimelineItemType.Success,
}, },
new() new()
{ {
Time = DateTime.Now, Time = DateTime.Now,
TimeFormat = "HH:mm:ss",
Description = "Item 2", Description = "Item 2",
Content = "Content 2", Header = "发布成功",
ItemType = TimelineItemType.Success,
},
new()
{
Time = DateTime.Now,
TimeFormat = "HH:mm:ss",
Description = "Item 3",
Content = "Content 3",
ItemType = TimelineItemType.Ongoing, ItemType = TimelineItemType.Ongoing,
}, },
new() new()
{ {
Time = DateTime.Now, Time = DateTime.Now,
TimeFormat = "HH:mm:ss", Description = "Item 3",
Description = "Item 4", Header = "审核失败",
Content = "Content 4" ItemType = TimelineItemType.Error,
}, }
new()
{
Time = DateTime.Now,
TimeFormat = "HH:mm:ss",
Description = "Item 5",
Content = "Content 5"
},
}; };
} }
@@ -54,6 +37,6 @@ public class TimelineItemViewModel: ObservableObject
public DateTime Time { get; set; } public DateTime Time { get; set; }
public string? TimeFormat { get; set; } public string? TimeFormat { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public string? Content { get; set; } public string? Header { get; set; }
public TimelineItemType ItemType { get; set; } public TimelineItemType ItemType { get; set; }
} }

View File

@@ -6,9 +6,9 @@
<Design.PreviewWith> <Design.PreviewWith>
<StackPanel Width="100" Spacing="20"> <StackPanel Width="100" Spacing="20">
<u:Timeline> <u:Timeline>
<u:TimelineItem Content="Hello" Time="2022-01-01" /> <u:TimelineItem Content="Hello" />
<u:TimelineItem Content="World" Time="2022-02-01" /> <u:TimelineItem Content="World" />
<u:TimelineItem Content="!" Time="2022-03-01" /> <u:TimelineItem Content="!" />
<u:TimelineItem /> <u:TimelineItem />
</u:Timeline> </u:Timeline>
</StackPanel> </StackPanel>
@@ -26,104 +26,172 @@
<converters:TimelineItemTypeToIconForegroundConverter <converters:TimelineItemTypeToIconForegroundConverter
x:Key="ForegroundConverter" x:Key="ForegroundConverter"
DefaultBrush="{DynamicResource DefaultTimelineIconForeground}" DefaultBrush="{DynamicResource DefaultTimelineIconForeground}"
ErrorBrush="{DynamicResource ErrorTimelineIconForeground}"
OngoingBrush="{DynamicResource OngoingTimelineIconForeground}" OngoingBrush="{DynamicResource OngoingTimelineIconForeground}"
SuccessBrush="{DynamicResource SuccessTimelineIconForeground}" SuccessBrush="{DynamicResource SuccessTimelineIconForeground}"
WarningBrush="{DynamicResource WarningTimelineIconForeground}" WarningBrush="{DynamicResource WarningTimelineIconForeground}" />
ErrorBrush="{DynamicResource ErrorTimelineIconForeground}" />
<ControlTheme x:Key="{x:Type u:TimelineItem}" TargetType="u:TimelineItem"> <ControlTheme x:Key="{x:Type u:TimelineItem}" TargetType="u:TimelineItem">
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="u:TimelineItem.Template"> <Setter Property="u:TimelineItem.Template">
<ControlTemplate TargetType="u:TimelineItem"> <ControlTemplate TargetType="u:TimelineItem">
<Grid ColumnDefinitions="Auto, *" RowDefinitions="*, Auto, *"> <Grid
Name="PART_RootGrid"
ColumnDefinitions="Auto, Auto, Auto"
RowDefinitions="Auto, Auto, Auto">
<!-- Icon and Axis -->
<Grid <Grid
Name="PART_IconAxisRoot"
Grid.Row="0" Grid.Row="0"
Grid.RowSpan="2" Grid.RowSpan="3"
Grid.Column="0" Grid.Column="1"
RowDefinitions="Auto, Auto, *"> RowDefinitions="Auto, *">
<Rectangle <Panel Grid.Row="0" Name="{x:Static u:TimelineItem.PART_Icon}">
Grid.Row="0" <ContentPresenter
Grid.Column="0" Margin="8"
Width="1"
Height="8"
HorizontalAlignment="Center"
VerticalAlignment="Top"
Classes="start"
Fill="{DynamicResource TimelineLineBrush}" />
<Panel Grid.Row="1">
<Ellipse
Name="PART_Indicator"
Width="8"
Height="8"
Margin="2"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Top" VerticalAlignment="Center"
Fill="{DynamicResource DefaultTimelineIconForeground}" /> Content="{TemplateBinding Icon}"
ContentTemplate="{TemplateBinding IconTemplate}" />
<Ellipse
Name="PART_DefaultIcon"
Width="12"
Height="12"
Margin="8"
IsVisible="False"
Fill="Gray" />
</Panel> </Panel>
<Rectangle <Rectangle
Grid.Row="2" Grid.Row="1"
Grid.Column="0" Width="2"
Width="1"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
Classes="end" Classes="end"
Fill="{DynamicResource TimelineLineBrush}" /> Fill="{DynamicResource TimelineLineBrush}" />
</Grid> </Grid>
<Rectangle
Grid.Row="2"
Grid.Column="0"
Width="1"
HorizontalAlignment="Center"
VerticalAlignment="Stretch"
Classes="end"
Fill="{DynamicResource TimelineLineBrush}" />
<ContentPresenter <ContentPresenter
Name="{x:Static u:TimelineItem.PART_Header}"
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="2"
HorizontalAlignment="Stretch" Margin="8,4"
VerticalAlignment="Bottom" VerticalAlignment="Top"
Foreground="Gray"> Content="{TemplateBinding Header}"
<ContentPresenter.Content> ContentTemplate="{TemplateBinding HeaderTemplate}"
FontSize="14"
Foreground="{DynamicResource SemiGrey9}" />
<ContentPresenter
Name="{x:Static u:TimelineItem.PART_Content}"
Grid.Row="1"
Grid.Column="2"
Margin="8,2"
VerticalAlignment="Top"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
TextElement.FontSize="12"
TextElement.Foreground="Gray" />
<TextBlock
Name="{x:Static u:TimelineItem.PART_Time}"
Grid.Row="0"
Grid.Column="0"
Margin="8,2"
VerticalAlignment="Top"
FontSize="12"
Foreground="Gray"
TextWrapping="Wrap">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource FormatConverter}"> <MultiBinding Converter="{StaticResource FormatConverter}">
<Binding Path="Time" RelativeSource="{RelativeSource TemplatedParent}" /> <Binding Path="Time" RelativeSource="{RelativeSource TemplatedParent}" />
<Binding Path="TimeFormat" RelativeSource="{RelativeSource TemplatedParent}" /> <Binding Path="TimeFormat" RelativeSource="{RelativeSource TemplatedParent}" />
</MultiBinding> </MultiBinding>
</ContentPresenter.Content> </TextBlock.Text>
</ContentPresenter> </TextBlock>
<ContentPresenter
Name="content"
Grid.Row="1"
Grid.Column="1"
Margin="0,0,0,16"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Grid> </Grid>
</ControlTemplate> </ControlTemplate>
</Setter> </Setter>
<Style Selector="^:first /template/ Rectangle.start">
<Setter Property="Rectangle.Fill" Value="Transparent" />
</Style>
<Style Selector="^:last /template/ Rectangle.end"> <Style Selector="^:last /template/ Rectangle.end">
<Setter Property="Rectangle.Fill" Value="Transparent" /> <Setter Property="Rectangle.Fill" Value="Transparent" />
</Style> </Style>
<Style Selector="^:none /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{Binding RelativeSource={RelativeSource Mode=FindAncestor, AncestorType=u:TimelineItem}, Path=IconForeground}" /> <Setter Property="IsVisible" Value="True"/>
</Style> </Style>
<Style Selector="^:not(:none):default /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon[Type=Default] /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{DynamicResource DefaultTimelineIconForeground}" /> <Setter Property="Fill" Value="{DynamicResource SemiGrey6}"/>
</Style> </Style>
<Style Selector="^:not(:none):ongoing /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon[Type=Error] /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{DynamicResource OngoingTimelineIconForeground}" /> <Setter Property="Fill" Value="{DynamicResource SemiRed6}"/>
</Style> </Style>
<Style Selector="^:not(:none):success /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon[Type=Ongoing] /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{DynamicResource SuccessTimelineIconForeground}" /> <Setter Property="Fill" Value="{DynamicResource SemiBlue6}"/>
</Style> </Style>
<Style Selector="^:not(:none):warning /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon[Type=Success] /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{DynamicResource WarningTimelineIconForeground}" /> <Setter Property="Fill" Value="{DynamicResource SemiGreen6}"/>
</Style> </Style>
<Style Selector="^:not(:none):error /template/ Ellipse#PART_Indicator"> <Style Selector="^:empty-icon[Type=Warning] /template/ Ellipse#PART_DefaultIcon">
<Setter Property="Ellipse.Fill" Value="{DynamicResource ErrorTimelineIconForeground}" /> <Setter Property="Fill" Value="{DynamicResource SemiOrange6}"/>
</Style>
<Style Selector="^:all-left">
<Style Selector="^ /template/ ContentPresenter#PART_Header">
<Setter Property="Grid.Row" Value="0" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_Content">
<Setter Property="Grid.Row" Value="1" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Time">
<Setter Property="Grid.Row" Value="2" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="TextAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Margin" Value="8 2 8 12"></Setter>
</Style>
</Style>
<Style Selector="^:all-right">
<Style Selector="^ /template/ ContentPresenter#PART_Header">
<Setter Property="Grid.Row" Value="0" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Right" />
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_Content">
<Setter Property="Grid.Row" Value="1" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Right" />
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
<Style Selector="^ /template/ TextBlock#PART_Time">
<Setter Property="Grid.Row" Value="2" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="HorizontalAlignment" Value="Right" />
<Setter Property="Margin" Value="8 2 8 12"></Setter>
</Style>
</Style>
<Style Selector="^:separate">
<Style Selector="^ /template/ ContentPresenter#PART_Header">
<Setter Property="Grid.Row" Value="0" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
<Style Selector="^ /template/ ContentPresenter#PART_Content">
<Setter Property="Grid.Row" Value="1" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Margin" Value="8 2 8 12"></Setter>
</Style>
<Style Selector="^ /template/ TextBlock#PART_Time">
<Setter Property="Grid.Row" Value="0" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="TextAlignment" Value="Right" />
<Setter Property="HorizontalAlignment" Value="Right" />
</Style>
</Style> </Style>
</ControlTheme> </ControlTheme>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -1,56 +1,220 @@
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices.ComTypes;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Generators; using Avalonia.Controls.Generators;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Layout;
using Avalonia.Metadata;
namespace Ursa.Controls; namespace Ursa.Controls;
public class Timeline: ItemsControl public class Timeline: ItemsControl
{ {
private static readonly FuncTemplate<Panel?> DefaultPanel = new((Func<Panel>)(() => new TimelinePanel()));
public static readonly StyledProperty<IDataTemplate?> ItemDescriptionTemplateProperty = AvaloniaProperty.Register<Timeline, IDataTemplate?>( public static readonly StyledProperty<IBinding?> IconMemberBindingProperty = AvaloniaProperty.Register<Timeline, IBinding?>(
nameof(ItemDescriptionTemplate)); nameof(IconMemberBinding));
public IDataTemplate? ItemDescriptionTemplate [AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? IconMemberBinding
{ {
get => GetValue(ItemDescriptionTemplateProperty); get => GetValue(IconMemberBindingProperty);
set => SetValue(ItemDescriptionTemplateProperty, value); set => SetValue(IconMemberBindingProperty, value);
} }
public Timeline() public static readonly StyledProperty<IBinding?> HeaderMemberBindingProperty = AvaloniaProperty.Register<Timeline, IBinding?>(
nameof(HeaderMemberBinding));
[AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? HeaderMemberBinding
{ {
ItemsView.CollectionChanged+=ItemsViewOnCollectionChanged; get => GetValue(HeaderMemberBindingProperty);
set => SetValue(HeaderMemberBindingProperty, value);
} }
private void ItemsViewOnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) public static readonly StyledProperty<IBinding?> ContentMemberBindingProperty = AvaloniaProperty.Register<Timeline, IBinding?>(
nameof(ContentMemberBinding));
[AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? ContentMemberBinding
{ {
RefreshTimelineItems(); get => GetValue(ContentMemberBindingProperty);
set => SetValue(ContentMemberBindingProperty, value);
} }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
public static readonly StyledProperty<IDataTemplate?> IconTemplateProperty = AvaloniaProperty.Register<Timeline, IDataTemplate?>(
nameof(IconTemplate));
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IDataTemplate? IconTemplate
{ {
base.OnPropertyChanged(change); get => GetValue(IconTemplateProperty);
RefreshTimelineItems(); set => SetValue(IconTemplateProperty, value);
} }
private void RefreshTimelineItems() public static readonly StyledProperty<IDataTemplate?> DescriptionTemplateProperty = AvaloniaProperty.Register<Timeline, IDataTemplate?>(
nameof(DescriptionTemplate));
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IDataTemplate? DescriptionTemplate
{ {
for (int i = 0; i < this.LogicalChildren.Count; i++) get => GetValue(DescriptionTemplateProperty);
set => SetValue(DescriptionTemplateProperty, value);
}
public static readonly StyledProperty<IBinding?> TimeMemberBindingProperty = AvaloniaProperty.Register<Timeline, IBinding?>(
nameof(TimeMemberBinding));
[AssignBinding]
[InheritDataTypeFromItems(nameof(ItemsSource))]
public IBinding? TimeMemberBinding
{
get => GetValue(TimeMemberBindingProperty);
set => SetValue(TimeMemberBindingProperty, value);
}
public static readonly StyledProperty<string?> TimeFormatProperty = AvaloniaProperty.Register<Timeline, string?>(
nameof(TimeFormat), defaultValue:"yyyy-MM-dd HH:mm:ss");
public string? TimeFormat
{
get => GetValue(TimeFormatProperty);
set => SetValue(TimeFormatProperty, value);
}
public static readonly StyledProperty<TimelineDisplayMode> ModeProperty = AvaloniaProperty.Register<Timeline, TimelineDisplayMode>(
nameof(Mode));
public TimelineDisplayMode Mode
{
get => GetValue(ModeProperty);
set => SetValue(ModeProperty, value);
}
static Timeline()
{
ItemsPanelProperty.OverrideDefaultValue<Timeline>(DefaultPanel);
ModeProperty.Changed.AddClassHandler<Timeline, TimelineDisplayMode>((t, e) => { t.OnDisplayModeChanged(e); });
}
private void OnDisplayModeChanged(AvaloniaPropertyChangedEventArgs<TimelineDisplayMode> e)
{
if (this.ItemsPanelRoot is TimelinePanel panel)
{ {
if (this.LogicalChildren[i] is TimelineItem t) panel.Mode = e.NewValue.Value;
SetItemMode();
}
}
protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey)
{
recycleKey = null;
return item is not TimelineItem;
}
protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
{
if (item is TimelineItem t) return t;
return new TimelineItem();
}
protected override void PrepareContainerForItemOverride(Control container, object? item, int index)
{
base.PrepareContainerForItemOverride(container, item, index);
if (container is TimelineItem t)
{
bool start = index == 0;
bool end = index == ItemCount - 1;
t.SetEnd(start, end);
if (IconMemberBinding is not null)
{ {
t.SetIndex(i == 0, i == this.LogicalChildren.Count - 1); t.Bind(TimelineItem.IconProperty, IconMemberBinding);
} }
else if (this.LogicalChildren[i] is ContentPresenter { Child: TimelineItem t2 }) if (HeaderMemberBinding != null)
{ {
t2.SetIndex(i == 0, i == this.LogicalChildren.Count - 1); t.Bind(HeaderedContentControl.HeaderProperty, HeaderMemberBinding);
}
if (ContentMemberBinding != null)
{
t.Bind(ContentControl.ContentProperty, ContentMemberBinding);
}
if (TimeMemberBinding != null)
{
t.Bind(TimelineItem.TimeProperty, TimeMemberBinding);
}
t.SetIfUnset(TimelineItem.TimeFormatProperty, TimeFormat);
t.SetIfUnset(TimelineItem.IconTemplateProperty, IconTemplate);
t.SetIfUnset(HeaderedContentControl.HeaderTemplateProperty, ItemTemplate);
t.SetIfUnset(ContentControl.ContentTemplateProperty, DescriptionTemplate);
}
}
protected override Size ArrangeOverride(Size finalSize)
{
var panel = this.ItemsPanelRoot as TimelinePanel;
panel.Mode = this.Mode;
SetItemMode();
return base.ArrangeOverride(finalSize);
}
private void SetItemMode()
{
if (ItemsPanelRoot is TimelinePanel panel)
{
var items = panel.Children.OfType<TimelineItem>();
if (Mode == TimelineDisplayMode.Left)
{
foreach (var item in items)
{
SetIfUnset(item, TimelineItem.PositionProperty, TimelineItemPosition.Left);
}
}
else if (Mode == TimelineDisplayMode.Right)
{
foreach (var item in items)
{
SetIfUnset(item, TimelineItem.PositionProperty, TimelineItemPosition.Right);
}
}
else if (Mode == TimelineDisplayMode.Center)
{
foreach (var item in items)
{
SetIfUnset(item, TimelineItem.PositionProperty, TimelineItemPosition.Separate);
}
}
else if (Mode == TimelineDisplayMode.Alternate)
{
bool left = false;
foreach (var item in items)
{
if (left)
{
SetIfUnset(item, TimelineItem.PositionProperty, TimelineItemPosition.Left);
}
else
{
SetIfUnset(item, TimelineItem.PositionProperty, TimelineItemPosition.Right);
}
left = !left;
}
} }
} }
} }
private void SetIfUnset<T>(AvaloniaObject target, StyledProperty<T> property, T value)
{
if (!target.IsSet(property))
target.SetCurrentValue(property, value);
}
} }

View File

@@ -0,0 +1,22 @@
namespace Ursa.Controls;
public enum TimelineDisplayMode
{
Left,
Center,
Right,
Alternate,
}
/// <summary>
/// Placement of timeline.
/// Left means line is placed left to TimelineItem content.
/// Right means line is placed right to TimelineItem content.
/// Separate means line is placed between TimelineItem content and time.
/// </summary>
public enum TimelineItemPosition
{
Left,
Right,
Separate,
}

View File

@@ -1,45 +1,106 @@
using System.Globalization;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Metadata; using Avalonia.Controls.Metadata;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Media; using Avalonia.Media;
namespace Ursa.Controls; namespace Ursa.Controls;
[PseudoClasses(PC_First, PC_Last, PC_Default, PC_Ongoing, PC_Success, PC_Warning, PC_Error, PC_None)] [PseudoClasses(PC_First, PC_Last, PC_EmptyIcon, PC_AllLeft, PC_AllRight, PC_Separate)]
public class TimelineItem: ContentControl [TemplatePart(PART_Header, typeof(ContentPresenter))]
[TemplatePart(PART_Icon, typeof(Panel))]
[TemplatePart(PART_Content, typeof(ContentPresenter))]
[TemplatePart(PART_Time, typeof(TextBlock))]
[TemplatePart(PART_RootGrid, typeof(Grid))]
public class TimelineItem: HeaderedContentControl
{ {
private const string PC_First = ":first"; public const string PC_First = ":first";
private const string PC_Last = ":last"; public const string PC_Last = ":last";
private const string PC_Default = ":default"; public const string PC_EmptyIcon = ":empty-icon";
private const string PC_Ongoing = ":ongoing"; public const string PC_AllLeft=":all-left";
private const string PC_Success = ":success"; public const string PC_AllRight=":all-right";
private const string PC_Warning = ":warning"; public const string PC_Separate = ":separate";
private const string PC_Error = ":error"; public const string PART_Header = "PART_Header";
private const string PC_None = ":none"; public const string PART_Icon = "PART_Icon";
public const string PART_Content = "PART_Content";
public const string PART_Time = "PART_Time";
public const string PART_RootGrid = "PART_RootGrid";
private static readonly IReadOnlyDictionary<TimelineItemType, string> _itemTypeMapping = new Dictionary<TimelineItemType, string> private ContentPresenter? _headerPresenter;
private Panel? _iconPresenter;
private ContentPresenter? _contentPresenter;
private TextBlock? _timePresenter;
private Grid? _rootGrid;
public static readonly StyledProperty<object?> IconProperty = AvaloniaProperty.Register<TimelineItem, object?>(
nameof(Icon));
public object? Icon
{ {
{TimelineItemType.Default, PC_Default}, get => GetValue(IconProperty);
{TimelineItemType.Ongoing, PC_Ongoing}, set => SetValue(IconProperty, value);
{TimelineItemType.Success, PC_Success}, }
{TimelineItemType.Warning, PC_Warning},
{TimelineItemType.Error, PC_Error},
};
public static readonly StyledProperty<IBrush> IconForegroundProperty = public static readonly StyledProperty<IDataTemplate?> IconTemplateProperty = AvaloniaProperty.Register<TimelineItem, IDataTemplate?>(
AvaloniaProperty.Register<TimelineItem, IBrush>(nameof(IconForeground)); nameof(IconTemplate));
public IBrush IconForeground public IDataTemplate? IconTemplate
{ {
get => GetValue(IconForegroundProperty); get => GetValue(IconTemplateProperty);
set => SetValue(IconForegroundProperty, value); set => SetValue(IconTemplateProperty, value);
}
public static readonly StyledProperty<TimelineItemType> TypeProperty = AvaloniaProperty.Register<TimelineItem, TimelineItemType>(
nameof(Type));
public TimelineItemType Type
{
get => GetValue(TypeProperty);
set => SetValue(TypeProperty, value);
}
public static readonly StyledProperty<TimelineItemPosition> PositionProperty = AvaloniaProperty.Register<TimelineItem, TimelineItemPosition>(
nameof(Position), defaultValue: TimelineItemPosition.Right);
public TimelineItemPosition Position
{
get => GetValue(PositionProperty);
set => SetValue(PositionProperty, value);
}
public static readonly DirectProperty<TimelineItem, double> LeftWidthProperty = AvaloniaProperty.RegisterDirect<TimelineItem, double>(
nameof(LeftWidth), o => o.LeftWidth, (o, v) => o.LeftWidth = v);
private double _leftWidth;
public double LeftWidth
{
get => _leftWidth;
set => SetAndRaise(LeftWidthProperty, ref _leftWidth, value);
}
public static readonly DirectProperty<TimelineItem, double> IconWidthProperty = AvaloniaProperty.RegisterDirect<TimelineItem, double>(
nameof(IconWidth), o => o.IconWidth, (o, v) => o.IconWidth = v);
private double _iconWidth;
public double IconWidth
{
get => _iconWidth;
set => SetAndRaise(IconWidthProperty, ref _iconWidth, value);
}
public static readonly DirectProperty<TimelineItem, double> RightWidthProperty = AvaloniaProperty.RegisterDirect<TimelineItem, double>(
nameof(RightWidth), o => o.RightWidth, (o, v) => o.RightWidth = v);
private double _rightWidth;
public double RightWidth
{
get => _rightWidth;
set => SetAndRaise(RightWidthProperty, ref _rightWidth, value);
} }
public static readonly StyledProperty<DateTime> TimeProperty = AvaloniaProperty.Register<TimelineItem, DateTime>( public static readonly StyledProperty<DateTime> TimeProperty = AvaloniaProperty.Register<TimelineItem, DateTime>(
nameof(Time)); nameof(Time));
public DateTime Time public DateTime Time
{ {
get => GetValue(TimeProperty); get => GetValue(TimeProperty);
@@ -47,7 +108,7 @@ public class TimelineItem: ContentControl
} }
public static readonly StyledProperty<string?> TimeFormatProperty = AvaloniaProperty.Register<TimelineItem, string?>( public static readonly StyledProperty<string?> TimeFormatProperty = AvaloniaProperty.Register<TimelineItem, string?>(
nameof(TimeFormat), defaultValue:CultureInfo.CurrentUICulture.DateTimeFormat.ShortDatePattern); nameof(TimeFormat));
public string? TimeFormat public string? TimeFormat
{ {
@@ -55,47 +116,86 @@ public class TimelineItem: ContentControl
set => SetValue(TimeFormatProperty, value); set => SetValue(TimeFormatProperty, value);
} }
public static readonly StyledProperty<IDataTemplate> DescriptionTemplateProperty = AvaloniaProperty.Register<TimelineItem, IDataTemplate>(
nameof(DescriptionTemplate));
public IDataTemplate DescriptionTemplate
{
get => GetValue(DescriptionTemplateProperty);
set => SetValue(DescriptionTemplateProperty, value);
}
public static readonly StyledProperty<TimelineItemType> ItemTypeProperty = AvaloniaProperty.Register<TimelineItem, TimelineItemType>(
nameof(ItemType));
public TimelineItemType ItemType
{
get => GetValue(ItemTypeProperty);
set => SetValue(ItemTypeProperty, value);
}
internal void SetIndex(bool isFirst, bool isLast)
{
PseudoClasses.Set(PC_First, isFirst);
PseudoClasses.Set(PC_Last, isLast);
}
static TimelineItem() static TimelineItem()
{ {
ItemTypeProperty.Changed.AddClassHandler<TimelineItem>((o, e) => { o.OnItemTypeChanged(e); }); IconProperty.Changed.AddClassHandler<TimelineItem, object?>((item, args) => { item.OnIconChanged(args); });
IconForegroundProperty.Changed.AddClassHandler<TimelineItem>((o, e) => { o.OnIconForegroundChanged(e); }); PositionProperty.Changed.AddClassHandler<TimelineItem, TimelineItemPosition>((item, args) => { item.OnModeChanged(args); });
AffectsMeasure<TimelineItem>(LeftWidthProperty, RightWidthProperty, IconWidthProperty);
} }
private void OnItemTypeChanged(AvaloniaPropertyChangedEventArgs args) private void OnModeChanged(AvaloniaPropertyChangedEventArgs<TimelineItemPosition> args)
{ {
var oldValue = args.GetOldValue<TimelineItemType>(); SetMode(args.NewValue.Value);
var newValue = args.GetNewValue<TimelineItemType>();
PseudoClasses.Set(_itemTypeMapping[oldValue], false);
PseudoClasses.Set(_itemTypeMapping[newValue], true);
} }
private void OnIconForegroundChanged(AvaloniaPropertyChangedEventArgs args) private void SetMode(TimelineItemPosition mode)
{ {
IBrush? newValue = args.GetOldValue<IBrush?>(); PseudoClasses.Set(PC_AllLeft, mode == TimelineItemPosition.Left);
PseudoClasses.Set(PC_None, newValue is null); PseudoClasses.Set(PC_AllRight, mode == TimelineItemPosition.Right);
PseudoClasses.Set(PC_Separate, mode == TimelineItemPosition.Separate);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_rootGrid = e.NameScope.Find<Grid>(PART_RootGrid);
_headerPresenter = e.NameScope.Find<ContentPresenter>(PART_Header);
_iconPresenter = e.NameScope.Find<Panel>(PART_Icon);
_contentPresenter = e.NameScope.Find<ContentPresenter>(PART_Content);
_timePresenter = e.NameScope.Find<TextBlock>(PART_Time);
PseudoClasses.Set(PC_EmptyIcon, Icon is null);
SetMode(Position);
}
private void OnIconChanged(AvaloniaPropertyChangedEventArgs<object?> args)
{
PseudoClasses.Set(PC_EmptyIcon, args.NewValue.Value is null);
}
internal void SetEnd(bool start, bool end)
{
PseudoClasses.Set(PC_First, start);
PseudoClasses.Set(PC_Last, end);
}
internal (double left, double mid, double right) GetWidth()
{
if (_headerPresenter is null) return new ValueTuple<double, double, double>(0, 0, 0);
double header = _headerPresenter?.DesiredSize.Width ?? 0;
double icon = _iconPresenter?.DesiredSize.Width ?? 0;
double content = _contentPresenter?.DesiredSize.Width ?? 0;
double time = _timePresenter?.DesiredSize.Width ?? 0;
double max = Math.Max(header, content);
if (Position == TimelineItemPosition.Left)
{
max = Math.Max(max, time);
return (0, icon, max);
}
if (Position == TimelineItemPosition.Right)
{
max = Math.Max(max, time);
return (max , icon, 0);
}
if (Position == TimelineItemPosition.Separate)
{
return (time, icon, max);
}
return new ValueTuple<double, double, double>(0, 0, 0);
}
internal void SetWidth(double? left, double? mid, double? right)
{
if (_rootGrid is null) return;
_rootGrid.ColumnDefinitions[0].Width = new GridLength(left??0);
_rootGrid.ColumnDefinitions[1].Width = new GridLength(mid??0);
_rootGrid.ColumnDefinitions[2].Width = new GridLength(right??0);
}
internal void SetIfUnset<T>(AvaloniaProperty<T> property, T value)
{
if (!IsSet(property))
{
SetCurrentValue(property, value);
}
} }
} }

View File

@@ -0,0 +1,75 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
namespace Ursa.Controls;
public class TimelinePanel: Panel
{
public static readonly StyledProperty<TimelineDisplayMode> ModeProperty =
Timeline.ModeProperty.AddOwner<TimelinePanel>();
public TimelineDisplayMode Mode
{
get => GetValue(ModeProperty);
set => SetValue(ModeProperty, value);
}
static TimelinePanel()
{
AffectsMeasure<TimelinePanel>(ModeProperty);
}
protected override Size MeasureOverride(Size availableSize)
{
double left = 0;
double right = 0;
double icon = 0;
double height = 0;
foreach (var child in Children)
{
child.Measure(availableSize);
if (child is TimelineItem t)
{
var doubles = t.GetWidth();
left = Math.Max(left, doubles.left);
icon = Math.Max(icon, doubles.mid);
right = Math.Max(right, doubles.right);
}
height+=child.DesiredSize.Height;
}
return new Size(left+icon+right, height);
}
protected override Size ArrangeOverride(Size finalSize)
{
double left = 0, mid = 0, right = 0;
double height = 0;
foreach (var child in Children)
{
if (child is TimelineItem t)
{
var doubles = t.GetWidth();
left = Math.Max(left, doubles.left);
mid = Math.Max(mid, doubles.mid);
right = Math.Max(right, doubles.right);
}
}
Rect rect = new Rect(0, 0, left + mid + right, 0);
foreach (var child in Children)
{
if (child is TimelineItem t)
{
t.SetWidth(left, mid, right);
t.InvalidateArrange();
rect = rect.WithHeight(t.DesiredSize.Height);
child.Arrange(rect);
rect = rect.WithY(rect.Y + t.DesiredSize.Height);
height+=t.DesiredSize.Height;
}
}
return new Size(left + mid + right, height);
}
}