Calendar rendering.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
internal static class CalendarItemAccessor
|
||||
{
|
||||
public static bool TryGetTimeRange(CalendarItemViewModel item, out DateTimeOffset start, out DateTimeOffset end)
|
||||
{
|
||||
start = new DateTimeOffset(item.StartDate);
|
||||
end = new DateTimeOffset(item.EndDate);
|
||||
return end > start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="Wino.Calendar.Controls.CalendarPeriodControl"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:Wino.Calendar.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:skia="using:SkiaSharp.Views.Windows"
|
||||
xmlns:viewModels="using:Wino.Calendar.ViewModels.Data"
|
||||
x:Name="Root"
|
||||
Loaded="ControlLoaded"
|
||||
SizeChanged="ControlSizeChanged"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<DataTemplate x:Key="CalendarEventTemplate" x:DataType="viewModels:CalendarItemViewModel">
|
||||
<local:CalendarItemControl CalendarItem="{x:Bind}" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="MonthEventTemplate" x:DataType="viewModels:CalendarItemViewModel">
|
||||
<local:CalendarItemControl
|
||||
Margin="0,2,0,0"
|
||||
CalendarItem="{x:Bind}"
|
||||
IsCustomEventArea="True" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="TimedHeaderTemplate" x:DataType="local:HeaderTextLayout">
|
||||
<TextBlock
|
||||
Width="{x:Bind Width}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind Text}"
|
||||
TextAlignment="Center" />
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="MonthHeaderTemplate" x:DataType="local:HeaderTextLayout">
|
||||
<TextBlock
|
||||
Width="{x:Bind Width}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind Text}"
|
||||
TextAlignment="Center" />
|
||||
</DataTemplate>
|
||||
|
||||
<ItemsPanelTemplate x:Key="HorizontalItemsPanel">
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid x:Name="TimedRoot" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="44" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid x:Name="TimedHeaderHost">
|
||||
<skia:SKXamlCanvas x:Name="TimedHeaderCanvas" PaintSurface="TimedHeaderCanvasPaintSurface" />
|
||||
<ItemsControl
|
||||
x:Name="TimedHeadersItemsControl"
|
||||
ItemTemplate="{StaticResource TimedHeaderTemplate}"
|
||||
ItemsSource="{x:Bind TimedHeaderTexts, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<StaticResource ResourceKey="HorizontalItemsPanel" />
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer
|
||||
x:Name="TimedScrollViewer"
|
||||
Grid.Row="1"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
VerticalScrollMode="Enabled">
|
||||
<Grid x:Name="TimedViewport" Height="{x:Bind TimelineHeight, Mode=OneWay}">
|
||||
<skia:SKXamlCanvas x:Name="TimedStructureCanvas" PaintSurface="TimedStructureCanvasPaintSurface" />
|
||||
<Canvas x:Name="TimedItemsCanvas" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Grid x:Name="MonthRoot" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Height="36">
|
||||
<ItemsControl
|
||||
x:Name="MonthHeadersItemsControl"
|
||||
VerticalAlignment="Center"
|
||||
ItemTemplate="{StaticResource MonthHeaderTemplate}"
|
||||
ItemsSource="{x:Bind MonthHeaderTexts, Mode=OneWay}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<StaticResource ResourceKey="HorizontalItemsPanel" />
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
x:Name="MonthViewport"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<skia:SKXamlCanvas x:Name="MonthStructureCanvas" PaintSurface="MonthStructureCanvasPaintSurface" />
|
||||
<Canvas x:Name="MonthCellLabelsCanvas" />
|
||||
<Canvas x:Name="MonthItemsCanvas" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,835 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.WinUI;
|
||||
using Itenso.TimePeriod;
|
||||
using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Hosting;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using SkiaSharp;
|
||||
using SkiaSharp.Views.Windows;
|
||||
using Windows.UI;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
public sealed partial class CalendarPeriodControl : UserControl, INotifyPropertyChanged
|
||||
{
|
||||
private VisibleDateRange _currentRange = new(
|
||||
CalendarDisplayType.Month,
|
||||
DateOnly.FromDateTime(DateTime.Today),
|
||||
DateOnly.FromDateTime(DateTime.Today),
|
||||
DateOnly.FromDateTime(DateTime.Today),
|
||||
DateOnly.FromDateTime(DateTime.Today),
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
[DateOnly.FromDateTime(DateTime.Today)]);
|
||||
|
||||
private TimedCalendarLayoutResult _timedLayout = new([], 0, []);
|
||||
private MonthCalendarLayoutResult _monthLayout = new(0, 0, [], []);
|
||||
private INotifyCollectionChanged? _observableItemsSource;
|
||||
private double _timedDayWidth;
|
||||
private double _monthCellWidth;
|
||||
private double _monthCellHeight;
|
||||
private bool _hasPresentedState;
|
||||
private CalendarDisplayType _lastDisplayMode = CalendarDisplayType.Month;
|
||||
private DateOnly _lastDisplayDate = DateOnly.FromDateTime(DateTime.Today);
|
||||
private DayOfWeek _lastFirstDayOfWeek = DayOfWeek.Monday;
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial VisibleDateRange? VisibleRange { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial CalendarSettings? CalendarSettings { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial IReadOnlyList<CalendarItemViewModel>? CalendarItems { get; set; }
|
||||
|
||||
[GeneratedDependencyProperty]
|
||||
public partial string? TimedHeaderDateFormat { get; set; }
|
||||
|
||||
public CalendarPeriodControl() => InitializeComponent();
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private ObservableCollection<HeaderTextLayout> TimedHeaderTextsCollection { get; } = [];
|
||||
private ObservableCollection<HeaderTextLayout> MonthHeaderTextsCollection { get; } = [];
|
||||
private ObservableCollection<TimedItemLayout> TimedItemsCollection { get; } = [];
|
||||
private ObservableCollection<MonthCellLabelLayout> MonthCellLabelsCollection { get; } = [];
|
||||
private ObservableCollection<MonthItemLayout> MonthItemsCollection { get; } = [];
|
||||
|
||||
public IEnumerable<HeaderTextLayout> TimedHeaderTexts => TimedHeaderTextsCollection;
|
||||
public IEnumerable<HeaderTextLayout> MonthHeaderTexts => MonthHeaderTextsCollection;
|
||||
public IEnumerable<TimedItemLayout> TimedItems => TimedItemsCollection;
|
||||
public IEnumerable<MonthCellLabelLayout> MonthCellLabels => MonthCellLabelsCollection;
|
||||
public IEnumerable<MonthItemLayout> MonthItems => MonthItemsCollection;
|
||||
|
||||
public double TimedDayWidth
|
||||
{
|
||||
get => _timedDayWidth;
|
||||
private set
|
||||
{
|
||||
if (_timedDayWidth == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timedDayWidth = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double MonthCellWidth
|
||||
{
|
||||
get => _monthCellWidth;
|
||||
private set
|
||||
{
|
||||
if (_monthCellWidth == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_monthCellWidth = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double MonthCellHeight
|
||||
{
|
||||
get => _monthCellHeight;
|
||||
private set
|
||||
{
|
||||
if (_monthCellHeight == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_monthCellHeight = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public double TimelineHeight => TimedCalendarLayoutCalculator.GetTimelineHeight(GetHourHeight());
|
||||
|
||||
partial void OnVisibleRangeChanged(VisibleDateRange? newValue) => Refresh();
|
||||
partial void OnCalendarSettingsChanged(CalendarSettings? newValue) => Refresh();
|
||||
partial void OnTimedHeaderDateFormatChanged(string? newValue) => Refresh();
|
||||
|
||||
partial void OnCalendarItemsChanged(IReadOnlyList<CalendarItemViewModel>? newValue)
|
||||
{
|
||||
DetachCurrentItemsSource();
|
||||
AttachItemsSource(newValue);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ControlLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
AttachItemsSource(CalendarItems);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void ControlSizeChanged(object sender, SizeChangedEventArgs e) => Refresh();
|
||||
|
||||
private IEnumerable<CalendarItemViewModel> CurrentItems => CalendarItems ?? [];
|
||||
|
||||
private void AttachItemsSource(IReadOnlyList<CalendarItemViewModel>? itemsSource)
|
||||
{
|
||||
_observableItemsSource = itemsSource as INotifyCollectionChanged;
|
||||
|
||||
if (_observableItemsSource is not null)
|
||||
{
|
||||
_observableItemsSource.CollectionChanged += ItemsSourceCollectionChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachItemsSource(IReadOnlyList<CalendarItemViewModel>? itemsSource)
|
||||
{
|
||||
var observableItemsSource = itemsSource as INotifyCollectionChanged;
|
||||
|
||||
if (observableItemsSource is not null)
|
||||
{
|
||||
observableItemsSource.CollectionChanged -= ItemsSourceCollectionChanged;
|
||||
}
|
||||
|
||||
if (ReferenceEquals(_observableItemsSource, observableItemsSource))
|
||||
{
|
||||
_observableItemsSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachCurrentItemsSource()
|
||||
{
|
||||
if (_observableItemsSource is not null)
|
||||
{
|
||||
_observableItemsSource.CollectionChanged -= ItemsSourceCollectionChanged;
|
||||
_observableItemsSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ItemsSourceCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => Refresh();
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (!IsLoaded || ActualWidth <= 0 || VisibleRange is null || CalendarSettings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var transition = GetTransitionInfo();
|
||||
_currentRange = CreateLayoutRange(VisibleRange, CalendarSettings);
|
||||
|
||||
if (VisibleRange.DisplayType == CalendarDisplayType.Month)
|
||||
{
|
||||
RefreshMonthView();
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshTimedView();
|
||||
}
|
||||
|
||||
RunTransition(transition);
|
||||
_hasPresentedState = true;
|
||||
_lastDisplayMode = VisibleRange.DisplayType;
|
||||
_lastDisplayDate = VisibleRange.AnchorDate;
|
||||
_lastFirstDayOfWeek = CalendarSettings.FirstDayOfWeek;
|
||||
}
|
||||
|
||||
private void RefreshTimedView()
|
||||
{
|
||||
TimedRoot.Visibility = Visibility.Visible;
|
||||
MonthRoot.Visibility = Visibility.Collapsed;
|
||||
|
||||
TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : ActualWidth / _currentRange.Dates.Count;
|
||||
TimedViewport.Width = ActualWidth;
|
||||
TimedViewport.Height = TimelineHeight;
|
||||
|
||||
_timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, GetHourHeight());
|
||||
|
||||
ReplaceCollection(
|
||||
TimedHeaderTextsCollection,
|
||||
_timedLayout.VisibleDates.Select(date =>
|
||||
new HeaderTextLayout(
|
||||
date.ToDateTime(TimeOnly.MinValue).ToString(
|
||||
string.IsNullOrWhiteSpace(TimedHeaderDateFormat) ? "ddd dd" : TimedHeaderDateFormat,
|
||||
CalendarSettings!.CultureInfo),
|
||||
TimedDayWidth)));
|
||||
|
||||
var eventTemplate = (DataTemplate)Resources["CalendarEventTemplate"];
|
||||
ReplaceCollection(TimedItemsCollection, _timedLayout.Items.Select(item =>
|
||||
{
|
||||
PrepareDisplayMetadata(item.Item, item.Date);
|
||||
item.Template = eventTemplate;
|
||||
return item;
|
||||
}));
|
||||
RenderTimedItems();
|
||||
|
||||
TimedHeaderCanvas.Invalidate();
|
||||
TimedStructureCanvas.Invalidate();
|
||||
}
|
||||
|
||||
private void RefreshMonthView()
|
||||
{
|
||||
TimedRoot.Visibility = Visibility.Collapsed;
|
||||
MonthRoot.Visibility = Visibility.Visible;
|
||||
|
||||
var availableMonthHeight = Math.Max(0d, ActualHeight - MonthHeadersItemsControl.ActualHeight);
|
||||
_monthLayout = MonthCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, availableMonthHeight);
|
||||
|
||||
MonthCellWidth = _monthLayout.CellWidth;
|
||||
MonthCellHeight = _monthLayout.CellHeight;
|
||||
|
||||
MonthViewport.Width = ActualWidth;
|
||||
MonthViewport.Height = availableMonthHeight;
|
||||
|
||||
ReplaceCollection(
|
||||
MonthHeaderTextsCollection,
|
||||
Enumerable.Range(0, MonthCalendarLayoutCalculator.ColumnCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var day = (DayOfWeek)(((int)CalendarSettings!.FirstDayOfWeek + index) % 7);
|
||||
return new HeaderTextLayout(
|
||||
CalendarSettings.CultureInfo.DateTimeFormat.AbbreviatedDayNames[(int)day],
|
||||
MonthCellWidth);
|
||||
}));
|
||||
|
||||
ReplaceCollection(
|
||||
MonthCellLabelsCollection,
|
||||
_monthLayout.Cells.Select(cell =>
|
||||
new MonthCellLabelLayout(
|
||||
cell.Date.Day.ToString(CalendarSettings!.CultureInfo),
|
||||
cell.Date.Month == VisibleRange!.AnchorDate.Month && cell.Date.Year == VisibleRange.AnchorDate.Year ? 1d : 0.55d,
|
||||
new LayoutRect(cell.Bounds.X + 4, cell.Bounds.Y + 2, cell.Bounds.Width, cell.Bounds.Height))));
|
||||
|
||||
var monthEventTemplate = (DataTemplate)Resources["MonthEventTemplate"];
|
||||
ReplaceCollection(MonthItemsCollection, _monthLayout.Items.Select(item =>
|
||||
{
|
||||
PrepareDisplayMetadata(item.Item, item.Date);
|
||||
item.Template = monthEventTemplate;
|
||||
return item;
|
||||
}));
|
||||
RenderMonthCellLabels();
|
||||
RenderMonthItems();
|
||||
|
||||
MonthStructureCanvas.Invalidate();
|
||||
}
|
||||
|
||||
private void PrepareDisplayMetadata(CalendarItemViewModel item, DateOnly date)
|
||||
{
|
||||
if (CalendarSettings is null || item is not ICalendarItemViewModel calendarItemViewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
calendarItemViewModel.DisplayingPeriod = new TimeRange(
|
||||
date.ToDateTime(TimeOnly.MinValue),
|
||||
date.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
calendarItemViewModel.CalendarSettings = CalendarSettings;
|
||||
}
|
||||
|
||||
private static VisibleDateRange CreateLayoutRange(VisibleDateRange visibleRange, CalendarSettings calendarSettings)
|
||||
{
|
||||
if (visibleRange.DisplayType != CalendarDisplayType.Month)
|
||||
{
|
||||
return visibleRange;
|
||||
}
|
||||
|
||||
var start = AlignToWeekStart(visibleRange.StartDate, calendarSettings.FirstDayOfWeek);
|
||||
var end = AlignToWeekEnd(visibleRange.EndDate, calendarSettings.FirstDayOfWeek);
|
||||
var totalDays = end.DayNumber - start.DayNumber + 1;
|
||||
|
||||
if (totalDays <= 35)
|
||||
{
|
||||
end = start.AddDays(34);
|
||||
}
|
||||
else if (totalDays < 42)
|
||||
{
|
||||
end = start.AddDays(41);
|
||||
}
|
||||
|
||||
var dates = Enumerable.Range(0, end.DayNumber - start.DayNumber + 1)
|
||||
.Select(offset => start.AddDays(offset))
|
||||
.ToArray();
|
||||
|
||||
return new VisibleDateRange(
|
||||
visibleRange.DisplayType,
|
||||
visibleRange.AnchorDate,
|
||||
start,
|
||||
end,
|
||||
visibleRange.PrimaryDate,
|
||||
dates.Length,
|
||||
dates.Contains(DateOnly.FromDateTime(DateTime.Today)),
|
||||
start.Year == end.Year && start.Month == end.Month,
|
||||
dates);
|
||||
}
|
||||
|
||||
private static DateOnly AlignToWeekStart(DateOnly date, DayOfWeek firstDayOfWeek)
|
||||
{
|
||||
var offset = ((int)date.DayOfWeek - (int)firstDayOfWeek + 7) % 7;
|
||||
return date.AddDays(-offset);
|
||||
}
|
||||
|
||||
private static DateOnly AlignToWeekEnd(DateOnly date, DayOfWeek firstDayOfWeek)
|
||||
{
|
||||
var lastDayOfWeek = (DayOfWeek)(((int)firstDayOfWeek + 6) % 7);
|
||||
var offset = ((int)lastDayOfWeek - (int)date.DayOfWeek + 7) % 7;
|
||||
return date.AddDays(offset);
|
||||
}
|
||||
|
||||
private void TimedHeaderCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
|
||||
{
|
||||
using var borderPaint = CreateLinePaint();
|
||||
var canvas = e.Surface.Canvas;
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scaleX = (float)(e.Info.Width / ActualWidth);
|
||||
var height = e.Info.Height;
|
||||
var dayWidth = (float)(_timedLayout.DayWidth * scaleX);
|
||||
|
||||
for (var index = 1; index < _timedLayout.VisibleDates.Count; index++)
|
||||
{
|
||||
var x = dayWidth * index;
|
||||
canvas.DrawLine(x, 0, x, height, borderPaint);
|
||||
}
|
||||
|
||||
canvas.DrawLine(0, height - 1, e.Info.Width, height - 1, borderPaint);
|
||||
}
|
||||
|
||||
private void TimedStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
|
||||
{
|
||||
using var linePaint = CreateLinePaint();
|
||||
using var defaultFillPaint = CreateFillPaint(GetDefaultHourBackground());
|
||||
using var workFillPaint = CreateFillPaint(GetWorkHourBackground());
|
||||
var canvas = e.Surface.Canvas;
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hourHeight = GetHourHeight();
|
||||
var timelineHeight = TimedCalendarLayoutCalculator.GetTimelineHeight(hourHeight);
|
||||
var scaleX = (float)(e.Info.Width / ActualWidth);
|
||||
var scaleY = (float)(e.Info.Height / timelineHeight);
|
||||
var dayWidth = (float)(_timedLayout.DayWidth * scaleX);
|
||||
var workDayStartHour = CalendarSettings?.WorkingHourStart.TotalHours ?? 9d;
|
||||
var workDayEndHour = CalendarSettings?.WorkingHourEnd.TotalHours ?? 17d;
|
||||
|
||||
for (var dayIndex = 0; dayIndex < _timedLayout.VisibleDates.Count; dayIndex++)
|
||||
{
|
||||
var x = dayIndex * dayWidth;
|
||||
|
||||
for (var hour = 0; hour < 24; hour++)
|
||||
{
|
||||
var y = (float)(hour * hourHeight * scaleY);
|
||||
var scaledHourHeight = (float)(hourHeight * scaleY);
|
||||
var fillPaint = hour >= workDayStartHour && hour < workDayEndHour ? workFillPaint : defaultFillPaint;
|
||||
canvas.DrawRect(x, y, dayWidth, scaledHourHeight, fillPaint);
|
||||
}
|
||||
}
|
||||
|
||||
for (var hour = 0; hour <= 24; hour++)
|
||||
{
|
||||
var y = (float)(hour * hourHeight * scaleY);
|
||||
canvas.DrawLine(0, y, e.Info.Width, y, linePaint);
|
||||
}
|
||||
|
||||
for (var index = 0; index <= _timedLayout.VisibleDates.Count; index++)
|
||||
{
|
||||
var x = dayWidth * index;
|
||||
canvas.DrawLine(x, 0, x, e.Info.Height, linePaint);
|
||||
}
|
||||
}
|
||||
|
||||
private void MonthStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
|
||||
{
|
||||
using var linePaint = CreateLinePaint();
|
||||
using var todayPaint = new SKPaint
|
||||
{
|
||||
Style = SKPaintStyle.Fill,
|
||||
Color = new SKColor(0, 120, 215, 26),
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
var canvas = e.Surface.Canvas;
|
||||
canvas.Clear(SKColors.Transparent);
|
||||
|
||||
if (_monthLayout.CellWidth <= 0 || _monthLayout.CellHeight <= 0 || MonthViewport.ActualWidth <= 0 || MonthViewport.ActualHeight <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cellWidth = (float)(e.Info.Width / MonthCalendarLayoutCalculator.ColumnCount);
|
||||
var cellHeight = (float)(e.Info.Height / MonthCalendarLayoutCalculator.RowCount);
|
||||
var today = DateOnly.FromDateTime(DateTime.Now.Date);
|
||||
|
||||
foreach (var cell in _monthLayout.Cells)
|
||||
{
|
||||
if (cell.Date != today)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
canvas.DrawRect((float)cell.Bounds.X, (float)cell.Bounds.Y, (float)cell.Bounds.Width, (float)cell.Bounds.Height, todayPaint);
|
||||
}
|
||||
|
||||
for (var row = 0; row <= MonthCalendarLayoutCalculator.RowCount; row++)
|
||||
{
|
||||
var y = row * cellHeight;
|
||||
canvas.DrawLine(0, y, e.Info.Width, y, linePaint);
|
||||
}
|
||||
|
||||
for (var column = 0; column <= MonthCalendarLayoutCalculator.ColumnCount; column++)
|
||||
{
|
||||
var x = column * cellWidth;
|
||||
canvas.DrawLine(x, 0, x, e.Info.Height, linePaint);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReplaceCollection<T>(ObservableCollection<T> target, IEnumerable<T> items)
|
||||
{
|
||||
target.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
target.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderTimedItems()
|
||||
{
|
||||
TimedItemsCanvas.Children.Clear();
|
||||
|
||||
foreach (var item in TimedItemsCollection)
|
||||
{
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Width = item.Bounds.Width,
|
||||
Height = item.Bounds.Height,
|
||||
Content = item.Item,
|
||||
ContentTemplate = item.Template
|
||||
};
|
||||
|
||||
Canvas.SetLeft(presenter, item.Bounds.X);
|
||||
Canvas.SetTop(presenter, item.Bounds.Y);
|
||||
TimedItemsCanvas.Children.Add(presenter);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderMonthCellLabels()
|
||||
{
|
||||
MonthCellLabelsCanvas.Children.Clear();
|
||||
|
||||
foreach (var label in MonthCellLabelsCollection)
|
||||
{
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Width = label.Bounds.Width,
|
||||
Height = label.Bounds.Height,
|
||||
Opacity = label.LabelOpacity,
|
||||
Text = label.DayText
|
||||
};
|
||||
|
||||
Canvas.SetLeft(textBlock, label.Bounds.X);
|
||||
Canvas.SetTop(textBlock, label.Bounds.Y);
|
||||
MonthCellLabelsCanvas.Children.Add(textBlock);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderMonthItems()
|
||||
{
|
||||
MonthItemsCanvas.Children.Clear();
|
||||
|
||||
foreach (var item in MonthItemsCollection)
|
||||
{
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Width = item.Bounds.Width,
|
||||
Height = item.Bounds.Height,
|
||||
Content = item.Item,
|
||||
ContentTemplate = item.Template
|
||||
};
|
||||
|
||||
Canvas.SetLeft(presenter, item.Bounds.X);
|
||||
Canvas.SetTop(presenter, item.Bounds.Y);
|
||||
MonthItemsCanvas.Children.Add(presenter);
|
||||
}
|
||||
}
|
||||
|
||||
private CalendarTransitionInfo GetTransitionInfo()
|
||||
{
|
||||
if (!_hasPresentedState || VisibleRange is null || CalendarSettings is null)
|
||||
{
|
||||
return new CalendarTransitionInfo(CalendarTransitionKind.None, 0);
|
||||
}
|
||||
|
||||
if (_lastDisplayMode != VisibleRange.DisplayType)
|
||||
{
|
||||
return new CalendarTransitionInfo(CalendarTransitionKind.ModeChange, 0);
|
||||
}
|
||||
|
||||
if (_lastDisplayDate != VisibleRange.AnchorDate)
|
||||
{
|
||||
return new CalendarTransitionInfo(
|
||||
CalendarTransitionKind.Navigation,
|
||||
VisibleRange.AnchorDate.CompareTo(_lastDisplayDate));
|
||||
}
|
||||
|
||||
if (_lastFirstDayOfWeek != CalendarSettings.FirstDayOfWeek)
|
||||
{
|
||||
return new CalendarTransitionInfo(CalendarTransitionKind.Refresh, 0);
|
||||
}
|
||||
|
||||
return new CalendarTransitionInfo(CalendarTransitionKind.None, 0);
|
||||
}
|
||||
|
||||
private void RunTransition(CalendarTransitionInfo transition)
|
||||
{
|
||||
if (transition.Kind == CalendarTransitionKind.None || VisibleRange is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (VisibleRange.DisplayType != CalendarDisplayType.Month)
|
||||
{
|
||||
RunTimedTransition(transition);
|
||||
return;
|
||||
}
|
||||
|
||||
var target = (UIElement)MonthRoot;
|
||||
var visual = ElementCompositionPreview.GetElementVisual(target);
|
||||
var compositor = visual.Compositor;
|
||||
|
||||
visual.CenterPoint = new Vector3((float)(target.RenderSize.Width * 0.5), (float)(target.RenderSize.Height * 0.5), 0f);
|
||||
visual.StopAnimation(nameof(visual.Offset));
|
||||
visual.StopAnimation(nameof(visual.Opacity));
|
||||
visual.StopAnimation(nameof(visual.Scale));
|
||||
|
||||
switch (transition.Kind)
|
||||
{
|
||||
case CalendarTransitionKind.Navigation:
|
||||
StartNavigationTransition(compositor, visual, transition.Direction, target.RenderSize.Width);
|
||||
break;
|
||||
case CalendarTransitionKind.ModeChange:
|
||||
StartModeTransition(compositor, visual);
|
||||
break;
|
||||
case CalendarTransitionKind.Refresh:
|
||||
StartRefreshTransition(compositor, visual);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RunTimedTransition(CalendarTransitionInfo transition)
|
||||
{
|
||||
var headerVisual = ElementCompositionPreview.GetElementVisual(TimedHeaderHost);
|
||||
var contentVisual = ElementCompositionPreview.GetElementVisual(TimedScrollViewer);
|
||||
var compositor = headerVisual.Compositor;
|
||||
|
||||
PrepareAnimatedVisual(headerVisual, TimedHeaderHost);
|
||||
PrepareAnimatedVisual(contentVisual, TimedScrollViewer);
|
||||
|
||||
switch (transition.Kind)
|
||||
{
|
||||
case CalendarTransitionKind.Navigation:
|
||||
StartTimedNavigationTransition(compositor, transition.Direction);
|
||||
break;
|
||||
case CalendarTransitionKind.ModeChange:
|
||||
StartTimedModeTransition(compositor);
|
||||
break;
|
||||
case CalendarTransitionKind.Refresh:
|
||||
StartTimedRefreshTransition(compositor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartNavigationTransition(Compositor compositor, Visual visual, int direction, double width)
|
||||
{
|
||||
var travel = (float)Math.Max(48d, Math.Min(160d, width * 0.08d));
|
||||
var startX = direction >= 0 ? travel : -travel;
|
||||
|
||||
var offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
|
||||
offsetAnimation.InsertKeyFrame(0f, new Vector3(startX, 0f, 0f));
|
||||
offsetAnimation.InsertKeyFrame(1f, Vector3.Zero);
|
||||
offsetAnimation.Duration = TimeSpan.FromMilliseconds(220);
|
||||
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.InsertKeyFrame(0f, 0.72f);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f);
|
||||
opacityAnimation.Duration = offsetAnimation.Duration;
|
||||
|
||||
visual.StartAnimation(nameof(visual.Offset), offsetAnimation);
|
||||
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
|
||||
}
|
||||
|
||||
private void StartTimedNavigationTransition(Compositor compositor, int direction)
|
||||
{
|
||||
var width = Math.Max(TimedRoot.RenderSize.Width, ActualWidth);
|
||||
var travel = (float)Math.Max(56d, Math.Min(184d, width * 0.09d));
|
||||
var signedTravel = direction >= 0 ? travel : -travel;
|
||||
var clipInset = (float)Math.Max(18d, Math.Min(64d, width * 0.05d));
|
||||
|
||||
StartTimedElementTransition(compositor, TimedHeaderHost, signedTravel * 0.45f, 0f, 0.78f, TimeSpan.FromMilliseconds(180), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f);
|
||||
StartTimedElementTransition(compositor, TimedScrollViewer, signedTravel, 0f, 0.68f, TimeSpan.FromMilliseconds(240), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f);
|
||||
}
|
||||
|
||||
private static void StartModeTransition(Compositor compositor, Visual visual)
|
||||
{
|
||||
var offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
|
||||
offsetAnimation.InsertKeyFrame(0f, new Vector3(0f, 18f, 0f));
|
||||
offsetAnimation.InsertKeyFrame(1f, Vector3.Zero);
|
||||
offsetAnimation.Duration = TimeSpan.FromMilliseconds(260);
|
||||
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.InsertKeyFrame(0f, 0f);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f);
|
||||
opacityAnimation.Duration = offsetAnimation.Duration;
|
||||
|
||||
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
|
||||
scaleAnimation.InsertKeyFrame(0f, new Vector3(0.985f, 0.985f, 1f));
|
||||
scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f));
|
||||
scaleAnimation.Duration = offsetAnimation.Duration;
|
||||
|
||||
visual.StartAnimation(nameof(visual.Offset), offsetAnimation);
|
||||
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
|
||||
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
|
||||
}
|
||||
|
||||
private void StartTimedModeTransition(Compositor compositor)
|
||||
{
|
||||
StartTimedElementTransition(compositor, TimedHeaderHost, 0f, 10f, 0f, TimeSpan.FromMilliseconds(180), 0f, 0f);
|
||||
StartTimedElementTransition(compositor, TimedScrollViewer, 0f, 18f, 0f, TimeSpan.FromMilliseconds(240), 0f, 0f);
|
||||
}
|
||||
|
||||
private static void StartRefreshTransition(Compositor compositor, Visual visual)
|
||||
{
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.InsertKeyFrame(0f, 0.82f);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f);
|
||||
opacityAnimation.Duration = TimeSpan.FromMilliseconds(160);
|
||||
|
||||
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
|
||||
}
|
||||
|
||||
private void StartTimedRefreshTransition(Compositor compositor)
|
||||
{
|
||||
StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedHeaderHost), 0.86f, TimeSpan.FromMilliseconds(140));
|
||||
StartOpacityTransition(compositor, ElementCompositionPreview.GetElementVisual(TimedScrollViewer), 0.8f, TimeSpan.FromMilliseconds(160));
|
||||
}
|
||||
|
||||
private static void PrepareAnimatedVisual(Visual visual, UIElement target)
|
||||
{
|
||||
visual.CenterPoint = new Vector3((float)(target.RenderSize.Width * 0.5), (float)(target.RenderSize.Height * 0.5), 0f);
|
||||
visual.StopAnimation(nameof(visual.Offset));
|
||||
visual.StopAnimation(nameof(visual.Opacity));
|
||||
visual.StopAnimation(nameof(visual.Scale));
|
||||
}
|
||||
|
||||
private static void StartTimedElementTransition(Compositor compositor, UIElement target, float offsetX, float offsetY, float startingOpacity, TimeSpan duration, float leftInset, float rightInset)
|
||||
{
|
||||
var visual = ElementCompositionPreview.GetElementVisual(target);
|
||||
PrepareAnimatedVisual(visual, target);
|
||||
|
||||
var easing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.16f, 1f), new Vector2(0.3f, 1f));
|
||||
var fadeEasing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.2f, 0f), new Vector2(0f, 1f));
|
||||
var clip = visual.Clip as InsetClip ?? compositor.CreateInsetClip();
|
||||
|
||||
clip.LeftInset = leftInset;
|
||||
clip.RightInset = rightInset;
|
||||
visual.Clip = clip;
|
||||
|
||||
var offsetAnimation = compositor.CreateVector3KeyFrameAnimation();
|
||||
offsetAnimation.InsertKeyFrame(0f, new Vector3(offsetX, offsetY, 0f));
|
||||
offsetAnimation.InsertKeyFrame(1f, Vector3.Zero, easing);
|
||||
offsetAnimation.Duration = duration;
|
||||
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.InsertKeyFrame(0f, startingOpacity);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f, fadeEasing);
|
||||
opacityAnimation.Duration = duration;
|
||||
|
||||
var scaleAnimation = compositor.CreateVector3KeyFrameAnimation();
|
||||
scaleAnimation.InsertKeyFrame(0f, new Vector3(0.996f, 0.996f, 1f));
|
||||
scaleAnimation.InsertKeyFrame(1f, new Vector3(1f, 1f, 1f), easing);
|
||||
scaleAnimation.Duration = duration;
|
||||
|
||||
var leftInsetAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
leftInsetAnimation.InsertKeyFrame(1f, 0f, easing);
|
||||
leftInsetAnimation.Duration = duration;
|
||||
|
||||
var rightInsetAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
rightInsetAnimation.InsertKeyFrame(1f, 0f, easing);
|
||||
rightInsetAnimation.Duration = duration;
|
||||
|
||||
visual.StartAnimation(nameof(visual.Offset), offsetAnimation);
|
||||
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
|
||||
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
|
||||
clip.StartAnimation(nameof(clip.LeftInset), leftInsetAnimation);
|
||||
clip.StartAnimation(nameof(clip.RightInset), rightInsetAnimation);
|
||||
}
|
||||
|
||||
private static void StartOpacityTransition(Compositor compositor, Visual visual, float startingOpacity, TimeSpan duration)
|
||||
{
|
||||
visual.StopAnimation(nameof(visual.Opacity));
|
||||
|
||||
var easing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.2f, 0f), new Vector2(0f, 1f));
|
||||
var opacityAnimation = compositor.CreateScalarKeyFrameAnimation();
|
||||
opacityAnimation.InsertKeyFrame(0f, startingOpacity);
|
||||
opacityAnimation.InsertKeyFrame(1f, 1f, easing);
|
||||
opacityAnimation.Duration = duration;
|
||||
|
||||
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
|
||||
}
|
||||
|
||||
private static SKPaint CreateLinePaint()
|
||||
{
|
||||
var strokeColor = GetStrokeColor();
|
||||
|
||||
return new SKPaint
|
||||
{
|
||||
Color = new SKColor(strokeColor.R, strokeColor.G, strokeColor.B, (byte)Math.Max(40, strokeColor.A / 2)),
|
||||
IsAntialias = false,
|
||||
StrokeWidth = 1
|
||||
};
|
||||
}
|
||||
|
||||
private static SKPaint CreateFillPaint(Brush brush)
|
||||
{
|
||||
return new SKPaint
|
||||
{
|
||||
Color = ToSkColor(brush),
|
||||
Style = SKPaintStyle.Fill,
|
||||
IsAntialias = false
|
||||
};
|
||||
}
|
||||
|
||||
private static SKColor ToSkColor(Brush brush)
|
||||
{
|
||||
return brush is SolidColorBrush solidColorBrush
|
||||
? new SKColor(solidColorBrush.Color.R, solidColorBrush.Color.G, solidColorBrush.Color.B, solidColorBrush.Color.A)
|
||||
: SKColors.Transparent;
|
||||
}
|
||||
|
||||
private Brush GetDefaultHourBackground()
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("LayerFillColorDefaultBrush", out var resource) && resource is Brush brush)
|
||||
{
|
||||
return brush;
|
||||
}
|
||||
|
||||
return new SolidColorBrush(Color.FromArgb(255, 28, 34, 42));
|
||||
}
|
||||
|
||||
private Brush GetWorkHourBackground()
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("SolidBackgroundFillColorBaseBrush", out var resource) && resource is SolidColorBrush solidBrush)
|
||||
{
|
||||
return new SolidColorBrush(Color.FromArgb(64, solidBrush.Color.R, solidBrush.Color.G, solidBrush.Color.B));
|
||||
}
|
||||
|
||||
return new SolidColorBrush(Color.FromArgb(255, 34, 40, 52));
|
||||
}
|
||||
|
||||
private double GetHourHeight() => CalendarSettings?.HourHeight ?? 60d;
|
||||
|
||||
private static Color GetStrokeColor()
|
||||
{
|
||||
if (Application.Current.Resources.TryGetValue("CardStrokeColorDefaultBrush", out var resource) &&
|
||||
resource is SolidColorBrush solidBrush)
|
||||
{
|
||||
return solidBrush.Color;
|
||||
}
|
||||
|
||||
return Color.FromArgb(96, 210, 210, 210);
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
private readonly record struct CalendarTransitionInfo(CalendarTransitionKind Kind, int Direction);
|
||||
|
||||
private enum CalendarTransitionKind
|
||||
{
|
||||
None,
|
||||
Navigation,
|
||||
ModeChange,
|
||||
Refresh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
public sealed class HeaderTextLayout
|
||||
{
|
||||
public HeaderTextLayout(string text, double width)
|
||||
{
|
||||
Text = text;
|
||||
Width = width;
|
||||
}
|
||||
|
||||
public string Text { get; set; }
|
||||
public double Width { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
public readonly record struct LayoutRect(double X, double Y, double Width, double Height);
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
public sealed class MonthCellLayout
|
||||
{
|
||||
public MonthCellLayout(DateOnly date, int index, int row, int column, LayoutRect bounds)
|
||||
{
|
||||
Date = date;
|
||||
Index = index;
|
||||
Row = row;
|
||||
Column = column;
|
||||
Bounds = bounds;
|
||||
}
|
||||
|
||||
public DateOnly Date { get; set; }
|
||||
public int Index { get; set; }
|
||||
public int Row { get; set; }
|
||||
public int Column { get; set; }
|
||||
public LayoutRect Bounds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MonthItemLayout
|
||||
{
|
||||
public MonthItemLayout(CalendarItemViewModel item, int cellIndex, DateOnly date, LayoutRect bounds, DataTemplate? template = null)
|
||||
{
|
||||
Item = item;
|
||||
CellIndex = cellIndex;
|
||||
Date = date;
|
||||
Bounds = bounds;
|
||||
Template = template;
|
||||
}
|
||||
|
||||
public CalendarItemViewModel Item { get; set; }
|
||||
public int CellIndex { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public LayoutRect Bounds { get; set; }
|
||||
public DataTemplate? Template { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MonthCellLabelLayout
|
||||
{
|
||||
public MonthCellLabelLayout(string dayText, double labelOpacity, LayoutRect bounds)
|
||||
{
|
||||
DayText = dayText;
|
||||
LabelOpacity = labelOpacity;
|
||||
Bounds = bounds;
|
||||
}
|
||||
|
||||
public string DayText { get; set; }
|
||||
public double LabelOpacity { get; set; }
|
||||
public LayoutRect Bounds { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record MonthCalendarLayoutResult(double CellWidth, double CellHeight, IReadOnlyList<MonthCellLayout> Cells, IReadOnlyList<MonthItemLayout> Items);
|
||||
|
||||
internal static class MonthCalendarLayoutCalculator
|
||||
{
|
||||
public const int ColumnCount = 7;
|
||||
public const int RowCount = 6;
|
||||
|
||||
private const double CellPadding = 4d;
|
||||
private const double DayLabelHeight = 20d;
|
||||
private const double ItemHeight = 18d;
|
||||
private const double ItemGap = 2d;
|
||||
|
||||
public static MonthCalendarLayoutResult Calculate(VisibleDateRange range, IEnumerable<CalendarItemViewModel> items, double availableWidth, double availableHeight)
|
||||
{
|
||||
var cellWidth = availableWidth <= 0 ? 0d : availableWidth / ColumnCount;
|
||||
var cellHeight = availableHeight <= 0 ? 0d : availableHeight / RowCount;
|
||||
var cells = range.Dates
|
||||
.Select((date, index) =>
|
||||
{
|
||||
var row = index / ColumnCount;
|
||||
var column = index % ColumnCount;
|
||||
return new MonthCellLayout(
|
||||
date,
|
||||
index,
|
||||
row,
|
||||
column,
|
||||
new LayoutRect(column * cellWidth, row * cellHeight, cellWidth, cellHeight));
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var itemLayouts = new List<MonthItemLayout>();
|
||||
|
||||
foreach (var cell in cells)
|
||||
{
|
||||
var cellItems = GetCellItems(items, cell.Date).ToList();
|
||||
|
||||
for (var index = 0; index < cellItems.Count; index++)
|
||||
{
|
||||
var y = cell.Bounds.Y + DayLabelHeight + CellPadding + (index * (ItemHeight + ItemGap));
|
||||
|
||||
if (y + ItemHeight > cell.Bounds.Y + cell.Bounds.Height - CellPadding)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
itemLayouts.Add(new MonthItemLayout(
|
||||
cellItems[index],
|
||||
cell.Index,
|
||||
cell.Date,
|
||||
new LayoutRect(
|
||||
cell.Bounds.X + CellPadding,
|
||||
y,
|
||||
Math.Max(0, cell.Bounds.Width - (CellPadding * 2)),
|
||||
ItemHeight)));
|
||||
}
|
||||
}
|
||||
|
||||
return new MonthCalendarLayoutResult(cellWidth, cellHeight, cells, itemLayouts);
|
||||
}
|
||||
|
||||
private static IEnumerable<CalendarItemViewModel> GetCellItems(IEnumerable<CalendarItemViewModel> items, DateOnly date)
|
||||
{
|
||||
var dayStart = date.ToDateTime(TimeOnly.MinValue);
|
||||
var dayEnd = dayStart.AddDays(1);
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!CalendarItemAccessor.TryGetTimeRange(item, out var start, out var end))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var localStart = start.LocalDateTime;
|
||||
var localEnd = end.LocalDateTime;
|
||||
|
||||
if (localEnd <= localStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (localStart < dayEnd && localEnd > dayStart)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Wino.Calendar.ViewModels.Data;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Calendar.Controls;
|
||||
|
||||
public sealed class TimedItemLayout
|
||||
{
|
||||
public TimedItemLayout(CalendarItemViewModel item, int dayIndex, DateOnly date, LayoutRect bounds, DataTemplate? template = null)
|
||||
{
|
||||
Item = item;
|
||||
DayIndex = dayIndex;
|
||||
Date = date;
|
||||
Bounds = bounds;
|
||||
Template = template;
|
||||
}
|
||||
|
||||
public CalendarItemViewModel Item { get; set; }
|
||||
public int DayIndex { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public LayoutRect Bounds { get; set; }
|
||||
public DataTemplate? Template { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record TimedCalendarLayoutResult(IReadOnlyList<DateOnly> VisibleDates, double DayWidth, IReadOnlyList<TimedItemLayout> Items);
|
||||
|
||||
internal static class TimedCalendarLayoutCalculator
|
||||
{
|
||||
public static double GetTimelineHeight(double hourHeight) => hourHeight * 24d;
|
||||
|
||||
public static TimedCalendarLayoutResult Calculate(VisibleDateRange range, IEnumerable<CalendarItemViewModel> items, double availableWidth, double hourHeight)
|
||||
{
|
||||
var visibleDates = range.Dates;
|
||||
var dayWidth = visibleDates.Count == 0 ? 0d : availableWidth / visibleDates.Count;
|
||||
var layouts = new List<TimedItemLayout>();
|
||||
|
||||
for (var dayIndex = 0; dayIndex < visibleDates.Count; dayIndex++)
|
||||
{
|
||||
var date = visibleDates[dayIndex];
|
||||
var daySegments = BuildDaySegments(items, date)
|
||||
.OrderBy(segment => segment.StartMinute)
|
||||
.ThenBy(segment => segment.EndMinute)
|
||||
.ToList();
|
||||
|
||||
foreach (var cluster in BuildClusters(daySegments))
|
||||
{
|
||||
AssignColumns(cluster);
|
||||
var columnCount = cluster.Max(segment => segment.ColumnIndex) + 1;
|
||||
var subColumnWidth = columnCount == 0 ? dayWidth : dayWidth / columnCount;
|
||||
|
||||
foreach (var segment in cluster)
|
||||
{
|
||||
var x = (dayIndex * dayWidth) + (segment.ColumnIndex * subColumnWidth) + 2;
|
||||
var width = Math.Max(0, subColumnWidth - 4);
|
||||
var y = (segment.StartMinute / 60d) * hourHeight;
|
||||
var height = Math.Max(1, ((segment.EndMinute - segment.StartMinute) / 60d) * hourHeight);
|
||||
|
||||
layouts.Add(new TimedItemLayout(segment.Item, dayIndex, date, new LayoutRect(x, y, width, height)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new TimedCalendarLayoutResult(visibleDates, dayWidth, layouts);
|
||||
}
|
||||
|
||||
private static List<Segment> BuildDaySegments(IEnumerable<CalendarItemViewModel> items, DateOnly date)
|
||||
{
|
||||
var dayStart = date.ToDateTime(TimeOnly.MinValue);
|
||||
var dayEnd = dayStart.AddDays(1);
|
||||
var segments = new List<Segment>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!CalendarItemAccessor.TryGetTimeRange(item, out var start, out var end))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var localStart = start.LocalDateTime;
|
||||
var localEnd = end.LocalDateTime;
|
||||
|
||||
if (localEnd <= localStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var segmentStart = localStart > dayStart ? localStart : dayStart;
|
||||
var segmentEnd = localEnd < dayEnd ? localEnd : dayEnd;
|
||||
|
||||
if (segmentEnd <= segmentStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Add(new Segment(item, (segmentStart - dayStart).TotalMinutes, (segmentEnd - dayStart).TotalMinutes));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static IEnumerable<List<Segment>> BuildClusters(List<Segment> segments)
|
||||
{
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var cluster = new List<Segment> { segments[0] };
|
||||
var clusterEnd = segments[0].EndMinute;
|
||||
|
||||
for (var index = 1; index < segments.Count; index++)
|
||||
{
|
||||
var segment = segments[index];
|
||||
|
||||
if (segment.StartMinute < clusterEnd)
|
||||
{
|
||||
cluster.Add(segment);
|
||||
clusterEnd = Math.Max(clusterEnd, segment.EndMinute);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return cluster;
|
||||
cluster = [segment];
|
||||
clusterEnd = segment.EndMinute;
|
||||
}
|
||||
|
||||
yield return cluster;
|
||||
}
|
||||
|
||||
private static void AssignColumns(List<Segment> segments)
|
||||
{
|
||||
var columnEnds = new List<double>();
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var assignedColumn = -1;
|
||||
|
||||
for (var columnIndex = 0; columnIndex < columnEnds.Count; columnIndex++)
|
||||
{
|
||||
if (columnEnds[columnIndex] <= segment.StartMinute)
|
||||
{
|
||||
assignedColumn = columnIndex;
|
||||
columnEnds[columnIndex] = segment.EndMinute;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (assignedColumn < 0)
|
||||
{
|
||||
assignedColumn = columnEnds.Count;
|
||||
columnEnds.Add(segment.EndMinute);
|
||||
}
|
||||
|
||||
segment.ColumnIndex = assignedColumn;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Segment(CalendarItemViewModel Item, double StartMinute, double EndMinute)
|
||||
{
|
||||
public int ColumnIndex { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user