diff --git a/demo/Ursa.Demo.Desktop/Ursa.Demo.Desktop.csproj b/demo/Ursa.Demo.Desktop/Ursa.Demo.Desktop.csproj
index 3d3f388..64e44ce 100644
--- a/demo/Ursa.Demo.Desktop/Ursa.Demo.Desktop.csproj
+++ b/demo/Ursa.Demo.Desktop/Ursa.Demo.Desktop.csproj
@@ -6,6 +6,7 @@
net7.0
enable
true
+ false
diff --git a/demo/Ursa.Demo/Converters/TimelineIconConverter.cs b/demo/Ursa.Demo/Converters/TimelineIconConverter.cs
new file mode 100644
index 0000000..04d46f4
--- /dev/null
+++ b/demo/Ursa.Demo/Converters/TimelineIconConverter.cs
@@ -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();
+ }
+}
\ No newline at end of file
diff --git a/demo/Ursa.Demo/Pages/IntroductionDemo.axaml b/demo/Ursa.Demo/Pages/IntroductionDemo.axaml
index 560ab49..f06ee98 100644
--- a/demo/Ursa.Demo/Pages/IntroductionDemo.axaml
+++ b/demo/Ursa.Demo/Pages/IntroductionDemo.axaml
@@ -144,27 +144,29 @@
IPAddress="{Binding Address}" />
-
+
+ Content="Step 1"
+ Header="ToDo"
+ Type="Default"
+ Time="2023-01-14 09:24:05"/>
+ Content="Step 2"
+ Header="Start"
+ Position="Right"
+ Type="Ongoing"
+ Time="2024-01-04 22:32:58"/>
+ Content="Step 3"
+ Header="In Between"
+ Type="Warning"
+ Time="2024-01-05 00:08:29"/>
-
+ Content="Step 4"
+ Header="Finished"
+ Position="Right"
+ Type="Success"
+ Time="2024-01-05 00:27:44"/>
diff --git a/demo/Ursa.Demo/Pages/TimelineDemo.axaml b/demo/Ursa.Demo/Pages/TimelineDemo.axaml
index b217fad..262ac3e 100644
--- a/demo/Ursa.Demo/Pages/TimelineDemo.axaml
+++ b/demo/Ursa.Demo/Pages/TimelineDemo.axaml
@@ -4,55 +4,84 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:selectors="clr-namespace:Ursa.Demo.TemplateSelectors"
xmlns:u="https://irihi.tech/ursa"
xmlns:viewModels="clr-namespace:Ursa.Demo.ViewModels"
d:DesignHeight="450"
d:DesignWidth="800"
- x:CompileBindings="False"
+ x:CompileBindings="True"
x:DataType="viewModels:TimelineDemoViewModel"
mc:Ignorable="d">
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
diff --git a/demo/Ursa.Demo/TemplateSelectors/TimelineIconTemplateSelector.cs b/demo/Ursa.Demo/TemplateSelectors/TimelineIconTemplateSelector.cs
new file mode 100644
index 0000000..a278e6f
--- /dev/null
+++ b/demo/Ursa.Demo/TemplateSelectors/TimelineIconTemplateSelector.cs
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/demo/Ursa.Demo/ViewModels/TimelineDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/TimelineDemoViewModel.cs
index fc9e819..54cb092 100644
--- a/demo/Ursa.Demo/ViewModels/TimelineDemoViewModel.cs
+++ b/demo/Ursa.Demo/ViewModels/TimelineDemoViewModel.cs
@@ -11,41 +11,24 @@ public class TimelineDemoViewModel: ViewModelBase
new()
{
Time = DateTime.Now,
- TimeFormat = "yyyy-MM-dd HH:mm:ss",
Description = "Item 1",
- Content = "First",
+ Header = "审核中",
ItemType = TimelineItemType.Success,
},
new()
{
Time = DateTime.Now,
- TimeFormat = "HH:mm:ss",
Description = "Item 2",
- Content = "Content 2",
- ItemType = TimelineItemType.Success,
- },
- new()
- {
- Time = DateTime.Now,
- TimeFormat = "HH:mm:ss",
- Description = "Item 3",
- Content = "Content 3",
+ Header = "发布成功",
ItemType = TimelineItemType.Ongoing,
},
new()
{
Time = DateTime.Now,
- TimeFormat = "HH:mm:ss",
- Description = "Item 4",
- Content = "Content 4"
- },
- new()
- {
- Time = DateTime.Now,
- TimeFormat = "HH:mm:ss",
- Description = "Item 5",
- Content = "Content 5"
- },
+ Description = "Item 3",
+ Header = "审核失败",
+ ItemType = TimelineItemType.Error,
+ }
};
}
@@ -54,6 +37,6 @@ public class TimelineItemViewModel: ObservableObject
public DateTime Time { get; set; }
public string? TimeFormat { get; set; }
public string? Description { get; set; }
- public string? Content { get; set; }
+ public string? Header { get; set; }
public TimelineItemType ItemType { get; set; }
}
\ No newline at end of file
diff --git a/src/Ursa.Themes.Semi/Controls/Timeline.axaml b/src/Ursa.Themes.Semi/Controls/Timeline.axaml
index b8f62fa..913e62c 100644
--- a/src/Ursa.Themes.Semi/Controls/Timeline.axaml
+++ b/src/Ursa.Themes.Semi/Controls/Timeline.axaml
@@ -6,9 +6,9 @@
-
-
-
+
+
+
@@ -26,104 +26,172 @@
+ WarningBrush="{DynamicResource WarningTimelineIconForeground}" />
+
+
-
+
+
-
-
-
+
+
+ VerticalAlignment="Center"
+ Content="{TemplateBinding Icon}"
+ ContentTemplate="{TemplateBinding IconTemplate}" />
+
-
-
+ Grid.Column="2"
+ Margin="8,4"
+ VerticalAlignment="Top"
+ Content="{TemplateBinding Header}"
+ ContentTemplate="{TemplateBinding HeaderTemplate}"
+ FontSize="14"
+ Foreground="{DynamicResource SemiGrey9}" />
+
+
+
-
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ursa/Controls/Timeline/Timeline.cs b/src/Ursa/Controls/Timeline/Timeline.cs
index 3304ab0..a318a58 100644
--- a/src/Ursa/Controls/Timeline/Timeline.cs
+++ b/src/Ursa/Controls/Timeline/Timeline.cs
@@ -1,56 +1,220 @@
using System.Collections.Specialized;
-using System.Runtime.CompilerServices;
-using System.Runtime.InteropServices.ComTypes;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
+using Avalonia.Data;
+using Avalonia.Layout;
+using Avalonia.Metadata;
namespace Ursa.Controls;
public class Timeline: ItemsControl
{
+ private static readonly FuncTemplate DefaultPanel = new((Func)(() => new TimelinePanel()));
- public static readonly StyledProperty ItemDescriptionTemplateProperty = AvaloniaProperty.Register(
- nameof(ItemDescriptionTemplate));
+ public static readonly StyledProperty IconMemberBindingProperty = AvaloniaProperty.Register(
+ nameof(IconMemberBinding));
- public IDataTemplate? ItemDescriptionTemplate
+ [AssignBinding]
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
+ public IBinding? IconMemberBinding
{
- get => GetValue(ItemDescriptionTemplateProperty);
- set => SetValue(ItemDescriptionTemplateProperty, value);
+ get => GetValue(IconMemberBindingProperty);
+ set => SetValue(IconMemberBindingProperty, value);
}
- public Timeline()
+ public static readonly StyledProperty HeaderMemberBindingProperty = AvaloniaProperty.Register(
+ 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 ContentMemberBindingProperty = AvaloniaProperty.Register(
+ nameof(ContentMemberBinding));
+
+ [AssignBinding]
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
+ public IBinding? ContentMemberBinding
{
- RefreshTimelineItems();
+ get => GetValue(ContentMemberBindingProperty);
+ set => SetValue(ContentMemberBindingProperty, value);
+ }
+
+
+ public static readonly StyledProperty IconTemplateProperty = AvaloniaProperty.Register(
+ nameof(IconTemplate));
+
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
+ public IDataTemplate? IconTemplate
+ {
+ get => GetValue(IconTemplateProperty);
+ set => SetValue(IconTemplateProperty, value);
}
- protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ public static readonly StyledProperty DescriptionTemplateProperty = AvaloniaProperty.Register(
+ nameof(DescriptionTemplate));
+
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
+ public IDataTemplate? DescriptionTemplate
{
- base.OnPropertyChanged(change);
- RefreshTimelineItems();
+ get => GetValue(DescriptionTemplateProperty);
+ set => SetValue(DescriptionTemplateProperty, value);
}
- private void RefreshTimelineItems()
+ public static readonly StyledProperty TimeMemberBindingProperty = AvaloniaProperty.Register(
+ nameof(TimeMemberBinding));
+
+ [AssignBinding]
+ [InheritDataTypeFromItems(nameof(ItemsSource))]
+ public IBinding? TimeMemberBinding
{
- for (int i = 0; i < this.LogicalChildren.Count; i++)
+ get => GetValue(TimeMemberBindingProperty);
+ set => SetValue(TimeMemberBindingProperty, value);
+ }
+
+ public static readonly StyledProperty TimeFormatProperty = AvaloniaProperty.Register(
+ nameof(TimeFormat), defaultValue:"yyyy-MM-dd HH:mm:ss");
+
+ public string? TimeFormat
+ {
+ get => GetValue(TimeFormatProperty);
+ set => SetValue(TimeFormatProperty, value);
+ }
+
+
+ public static readonly StyledProperty ModeProperty = AvaloniaProperty.Register(
+ nameof(Mode));
+
+ public TimelineDisplayMode Mode
+ {
+ get => GetValue(ModeProperty);
+ set => SetValue(ModeProperty, value);
+ }
+
+ static Timeline()
+ {
+ ItemsPanelProperty.OverrideDefaultValue(DefaultPanel);
+ ModeProperty.Changed.AddClassHandler((t, e) => { t.OnDisplayModeChanged(e); });
+ }
+
+ private void OnDisplayModeChanged(AvaloniaPropertyChangedEventArgs 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();
+ 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(AvaloniaObject target, StyledProperty property, T value)
+ {
+ if (!target.IsSet(property))
+ target.SetCurrentValue(property, value);
+ }
}
\ No newline at end of file
diff --git a/src/Ursa/Controls/Timeline/TimelineDisplayMode.cs b/src/Ursa/Controls/Timeline/TimelineDisplayMode.cs
new file mode 100644
index 0000000..ec01a90
--- /dev/null
+++ b/src/Ursa/Controls/Timeline/TimelineDisplayMode.cs
@@ -0,0 +1,22 @@
+namespace Ursa.Controls;
+
+public enum TimelineDisplayMode
+{
+ Left,
+ Center,
+ Right,
+ Alternate,
+}
+
+///
+/// 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.
+///
+public enum TimelineItemPosition
+{
+ Left,
+ Right,
+ Separate,
+}
\ No newline at end of file
diff --git a/src/Ursa/Controls/Timeline/TimelineItem.cs b/src/Ursa/Controls/Timeline/TimelineItem.cs
index 0958a31..9eecece 100644
--- a/src/Ursa/Controls/Timeline/TimelineItem.cs
+++ b/src/Ursa/Controls/Timeline/TimelineItem.cs
@@ -1,45 +1,106 @@
-using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Metadata;
+using Avalonia.Controls.Presenters;
+using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Media;
namespace Ursa.Controls;
-[PseudoClasses(PC_First, PC_Last, PC_Default, PC_Ongoing, PC_Success, PC_Warning, PC_Error, PC_None)]
-public class TimelineItem: ContentControl
+[PseudoClasses(PC_First, PC_Last, PC_EmptyIcon, PC_AllLeft, PC_AllRight, PC_Separate)]
+[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";
- private const string PC_Last = ":last";
- private const string PC_Default = ":default";
- private const string PC_Ongoing = ":ongoing";
- private const string PC_Success = ":success";
- private const string PC_Warning = ":warning";
- private const string PC_Error = ":error";
- private const string PC_None = ":none";
+ public const string PC_First = ":first";
+ public const string PC_Last = ":last";
+ public const string PC_EmptyIcon = ":empty-icon";
+ public const string PC_AllLeft=":all-left";
+ public const string PC_AllRight=":all-right";
+ public const string PC_Separate = ":separate";
+ public const string PART_Header = "PART_Header";
+ 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 ContentPresenter? _headerPresenter;
+ private Panel? _iconPresenter;
+ private ContentPresenter? _contentPresenter;
+ private TextBlock? _timePresenter;
+ private Grid? _rootGrid;
+
+ public static readonly StyledProperty