Calendar rendering implementation.

This commit is contained in:
Burak Kaan Köse
2026-03-23 14:56:36 +01:00
parent 8586d0ef54
commit 1adba271e2
32 changed files with 11146 additions and 846 deletions
@@ -12,9 +12,11 @@ using Microsoft.UI.Composition;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using SkiaSharp;
using SkiaSharp.Views.Windows;
using Windows.Foundation;
using Windows.UI;
using Wino.Calendar.ViewModels.Data;
using Wino.Core.Domain.Enums;
@@ -25,6 +27,9 @@ namespace Wino.Calendar.Controls;
public sealed partial class CalendarPeriodControl : UserControl, INotifyPropertyChanged
{
private const double TimedHourColumnWidth = 64d;
private const double TimedGridIntervalMinutes = 30d;
private const double TimedSelectionIntervalMinutes = 30d;
private VisibleDateRange _currentRange = new(
CalendarDisplayType.Month,
DateOnly.FromDateTime(DateTime.Today),
@@ -62,6 +67,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
public CalendarPeriodControl() => InitializeComponent();
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<CalendarEmptySlotTappedEventArgs>? EmptySlotTapped;
private ObservableCollection<HeaderTextLayout> TimedHeaderTextsCollection { get; } = [];
private ObservableCollection<HeaderTextLayout> MonthHeaderTextsCollection { get; } = [];
@@ -209,20 +215,22 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
{
TimedRoot.Visibility = Visibility.Visible;
MonthRoot.Visibility = Visibility.Collapsed;
ResetTimedVisualState();
TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : ActualWidth / _currentRange.Dates.Count;
TimedViewport.Width = ActualWidth;
var timedSurfaceWidth = GetTimedSurfaceWidth();
TimedDayWidth = _currentRange.Dates.Count == 0 ? 0d : timedSurfaceWidth / _currentRange.Dates.Count;
TimedScrollContentGrid.Width = ActualWidth;
TimedViewport.Width = timedSurfaceWidth;
TimedViewport.Height = TimelineHeight;
_timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, ActualWidth, GetHourHeight());
_timedLayout = TimedCalendarLayoutCalculator.Calculate(_currentRange, CurrentItems, timedSurfaceWidth, GetHourHeight());
ReplaceCollection(
TimedHeaderTextsCollection,
_timedLayout.VisibleDates.Select(date =>
new HeaderTextLayout(
date.ToDateTime(TimeOnly.MinValue).ToString(
string.IsNullOrWhiteSpace(TimedHeaderDateFormat) ? "ddd dd" : TimedHeaderDateFormat,
CalendarSettings!.CultureInfo),
GetTimedHeaderText(date),
TimedDayWidth)));
var eventTemplate = (DataTemplate)Resources["CalendarEventTemplate"];
@@ -232,6 +240,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
item.Template = eventTemplate;
return item;
}));
RenderHourLabels();
RenderTimedItems();
TimedHeaderCanvas.Invalidate();
@@ -352,12 +361,14 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
var canvas = e.Surface.Canvas;
canvas.Clear(SKColors.Transparent);
if (_timedLayout.VisibleDates.Count == 0 || ActualWidth <= 0)
var timedSurfaceWidth = GetTimedSurfaceWidth();
if (_timedLayout.VisibleDates.Count == 0 || timedSurfaceWidth <= 0)
{
return;
}
var scaleX = (float)(e.Info.Width / ActualWidth);
var scaleX = (float)(e.Info.Width / timedSurfaceWidth);
var height = e.Info.Height;
var dayWidth = (float)(_timedLayout.DayWidth * scaleX);
@@ -373,41 +384,50 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void TimedStructureCanvasPaintSurface(object? sender, SKPaintSurfaceEventArgs e)
{
using var linePaint = CreateLinePaint();
using var minorLinePaint = CreateMinorLinePaint();
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)
var timedSurfaceWidth = GetTimedSurfaceWidth();
if (_timedLayout.VisibleDates.Count == 0 || timedSurfaceWidth <= 0)
{
return;
}
var hourHeight = GetHourHeight();
var timelineHeight = TimedCalendarLayoutCalculator.GetTimelineHeight(hourHeight);
var scaleX = (float)(e.Info.Width / ActualWidth);
var scaleX = (float)(e.Info.Width / timedSurfaceWidth);
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;
var intervalHeight = (float)(GetTimedGridIntervalHeight() * scaleY);
var intervalCount = (int)(24d * 60d / TimedGridIntervalMinutes);
for (var dayIndex = 0; dayIndex < _timedLayout.VisibleDates.Count; dayIndex++)
{
var x = dayIndex * dayWidth;
var isWorkingDay = CalendarSettings?.WorkingDays.Contains(_timedLayout.VisibleDates[dayIndex].DayOfWeek) == true;
for (var hour = 0; hour < 24; hour++)
for (var intervalIndex = 0; intervalIndex < intervalCount; intervalIndex++)
{
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);
var intervalStartHour = (intervalIndex * TimedGridIntervalMinutes) / 60d;
var y = intervalIndex * intervalHeight;
var fillPaint = isWorkingDay && intervalStartHour >= workDayStartHour && intervalStartHour < workDayEndHour
? workFillPaint
: defaultFillPaint;
canvas.DrawRect(x, y, dayWidth, intervalHeight, fillPaint);
}
}
for (var hour = 0; hour <= 24; hour++)
for (var intervalIndex = 0; intervalIndex <= intervalCount; intervalIndex++)
{
var y = (float)(hour * hourHeight * scaleY);
canvas.DrawLine(0, y, e.Info.Width, y, linePaint);
var y = intervalIndex * intervalHeight;
var paint = intervalIndex % 2 == 0 ? linePaint : minorLinePaint;
canvas.DrawLine(0, y, e.Info.Width, y, paint);
}
for (var index = 0; index <= _timedLayout.VisibleDates.Count; index++)
@@ -472,6 +492,34 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void RenderHourLabels()
{
HourLabelsCanvas.Children.Clear();
HourLabelsCanvas.Height = TimelineHeight;
var hourHeight = GetHourHeight();
var labelWidth = Math.Max(0d, TimedHourColumnWidth - 10d);
for (var hour = 0; hour <= 24; hour++)
{
var textBlock = new TextBlock
{
Width = labelWidth,
Text = GetTimedHourLabelText(hour),
TextAlignment = TextAlignment.Right,
Opacity = 0.72
};
var y = hour == 24
? Math.Max(0d, TimelineHeight - 20d)
: Math.Max(0d, (hour * hourHeight) - 10d);
Canvas.SetLeft(textBlock, 0d);
Canvas.SetTop(textBlock, y);
HourLabelsCanvas.Children.Add(textBlock);
}
}
private void RenderTimedItems()
{
TimedItemsCanvas.Children.Clear();
@@ -532,6 +580,70 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void TimedInteractionLayerTapped(object sender, TappedRoutedEventArgs e)
{
if (_timedLayout.VisibleDates.Count == 0 || _timedLayout.DayWidth <= 0)
{
return;
}
var position = e.GetPosition(TimedViewport);
var dayIndex = Math.Clamp((int)(position.X / _timedLayout.DayWidth), 0, _timedLayout.VisibleDates.Count - 1);
var intervalHeight = GetTimedSelectionIntervalHeight();
var slotIndex = Math.Clamp((int)(position.Y / intervalHeight), 0, (int)((24d * 60d / TimedSelectionIntervalMinutes) - 1));
var slotStart = TimeSpan.FromMinutes(slotIndex * TimedSelectionIntervalMinutes);
var clickedDate = _timedLayout.VisibleDates[dayIndex].ToDateTime(TimeOnly.MinValue).Add(slotStart);
EmptySlotTapped?.Invoke(
this,
new CalendarEmptySlotTappedEventArgs(
clickedDate,
new Point(dayIndex * _timedLayout.DayWidth, slotIndex * intervalHeight),
new Size(_timedLayout.DayWidth, intervalHeight)));
}
private void MonthInteractionLayerTapped(object sender, TappedRoutedEventArgs e)
{
if (_monthLayout.Cells.Count == 0 || _monthLayout.CellWidth <= 0 || _monthLayout.CellHeight <= 0)
{
return;
}
var position = e.GetPosition(MonthViewport);
var column = Math.Clamp((int)(position.X / _monthLayout.CellWidth), 0, MonthCalendarLayoutCalculator.ColumnCount - 1);
var row = Math.Clamp((int)(position.Y / _monthLayout.CellHeight), 0, MonthCalendarLayoutCalculator.RowCount - 1);
var cellIndex = Math.Clamp((row * MonthCalendarLayoutCalculator.ColumnCount) + column, 0, _monthLayout.Cells.Count - 1);
var cell = _monthLayout.Cells[cellIndex];
EmptySlotTapped?.Invoke(
this,
new CalendarEmptySlotTappedEventArgs(
cell.Date.ToDateTime(TimeOnly.MinValue),
new Point(cell.Bounds.X, cell.Bounds.Y),
new Size(cell.Bounds.Width, cell.Bounds.Height)));
}
private double GetTimedSurfaceWidth() => Math.Max(0d, ActualWidth - TimedHourColumnWidth);
private string GetTimedHeaderText(DateOnly date)
{
if (!string.IsNullOrWhiteSpace(TimedHeaderDateFormat) && CalendarSettings is not null)
{
try
{
return date.ToDateTime(TimeOnly.MinValue).ToString(TimedHeaderDateFormat, CalendarSettings.CultureInfo);
}
catch (FormatException)
{
}
}
return CalendarSettings?.GetTimedDayHeaderText(date) ?? date.ToDateTime(TimeOnly.MinValue).ToString("ddd dd");
}
private string GetTimedHourLabelText(int hour)
=> CalendarSettings?.GetTimedHourLabelText(hour) ?? $"{hour:00}:00";
private CalendarTransitionInfo GetTransitionInfo()
{
if (!_hasPresentedState || VisibleRange is null || CalendarSettings is null)
@@ -597,11 +709,9 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
private void RunTimedTransition(CalendarTransitionInfo transition)
{
var headerVisual = ElementCompositionPreview.GetElementVisual(TimedHeaderHost);
var contentVisual = ElementCompositionPreview.GetElementVisual(TimedScrollViewer);
var compositor = headerVisual.Compositor;
var compositor = contentVisual.Compositor;
PrepareAnimatedVisual(headerVisual, TimedHeaderHost);
PrepareAnimatedVisual(contentVisual, TimedScrollViewer);
switch (transition.Kind)
@@ -618,6 +728,11 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
}
}
private void ResetTimedVisualState()
{
ResetAnimatedElement(TimedScrollViewer);
}
private static void StartNavigationTransition(Compositor compositor, Visual visual, int direction, double width)
{
var travel = (float)Math.Max(48d, Math.Min(160d, width * 0.08d));
@@ -644,8 +759,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
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);
StartTimedElementTransition(compositor, TimedScrollViewer, signedTravel, 0f, 0.68f, TimeSpan.FromMilliseconds(240), direction >= 0 ? 0f : clipInset, direction >= 0 ? clipInset : 0f, animateScale: false);
}
private static void StartModeTransition(Compositor compositor, Visual visual)
@@ -672,8 +786,7 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
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);
StartTimedElementTransition(compositor, TimedScrollViewer, 0f, 18f, 0f, TimeSpan.FromMilliseconds(240), 0f, 0f, animateScale: false);
}
private static void StartRefreshTransition(Compositor compositor, Visual visual)
@@ -688,7 +801,6 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
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));
}
@@ -700,7 +812,25 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
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)
private static void ResetAnimatedElement(UIElement target)
{
var visual = ElementCompositionPreview.GetElementVisual(target);
PrepareAnimatedVisual(visual, target);
visual.Offset = Vector3.Zero;
visual.Opacity = 1f;
visual.Scale = new Vector3(1f, 1f, 1f);
if (visual.Clip is InsetClip clip)
{
clip.StopAnimation(nameof(clip.LeftInset));
clip.StopAnimation(nameof(clip.RightInset));
clip.LeftInset = 0f;
clip.RightInset = 0f;
}
}
private static void StartTimedElementTransition(Compositor compositor, UIElement target, float offsetX, float offsetY, float startingOpacity, TimeSpan duration, float leftInset, float rightInset, bool animateScale = true)
{
var visual = ElementCompositionPreview.GetElementVisual(target);
PrepareAnimatedVisual(visual, target);
@@ -723,11 +853,6 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
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;
@@ -738,7 +863,20 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
visual.StartAnimation(nameof(visual.Offset), offsetAnimation);
visual.StartAnimation(nameof(visual.Opacity), opacityAnimation);
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
if (animateScale)
{
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;
visual.StartAnimation(nameof(visual.Scale), scaleAnimation);
}
else
{
visual.Scale = new Vector3(1f, 1f, 1f);
}
clip.StartAnimation(nameof(clip.LeftInset), leftInsetAnimation);
clip.StartAnimation(nameof(clip.RightInset), rightInsetAnimation);
}
@@ -768,6 +906,18 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
};
}
private static SKPaint CreateMinorLinePaint()
{
var strokeColor = GetStrokeColor();
return new SKPaint
{
Color = new SKColor(strokeColor.R, strokeColor.G, strokeColor.B, (byte)Math.Max(20, strokeColor.A / 4)),
IsAntialias = false,
StrokeWidth = 1
};
}
private static SKPaint CreateFillPaint(Brush brush)
{
return new SKPaint
@@ -805,6 +955,12 @@ public sealed partial class CalendarPeriodControl : UserControl, INotifyProperty
return new SolidColorBrush(Color.FromArgb(255, 34, 40, 52));
}
private static double GetTimedGridIntervalHeight(double hourHeight) => hourHeight * (TimedGridIntervalMinutes / 60d);
private double GetTimedGridIntervalHeight() => GetTimedGridIntervalHeight(GetHourHeight());
private double GetTimedSelectionIntervalHeight() => GetHourHeight() * (TimedSelectionIntervalMinutes / 60d);
private double GetHourHeight() => CalendarSettings?.HourHeight ?? 60d;
private static Color GetStrokeColor()