Files
logikonline e55230c441
All checks were successful
CI / Build (Linux) (push) Successful in 21s
refactor: replace Console.WriteLine with DiagnosticLog service
Replace 495+ Console.WriteLine debug statements across handlers, dispatching, services, views, and window components with centralized DiagnosticLog service for proper logging infrastructure. Add new DiagnosticLog.cs service with Debug/Error methods to eliminate debug logging pollution in production code.
2026-03-06 22:06:08 -05:00

783 lines
25 KiB
C#

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using SkiaSharp;
using System.Collections;
using System.Collections.Specialized;
using Microsoft.Maui.Graphics;
using Microsoft.Maui.Platform.Linux.Services;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Base class for Skia-rendered items views (CollectionView, ListView).
/// Provides item rendering, scrolling, and virtualization.
/// </summary>
public class SkiaItemsView : SkiaView
{
private IEnumerable? _itemsSource;
private List<object> _items = new();
protected float _scrollOffset;
private float _itemHeight = 44; // Default item height
private float _itemSpacing = 0;
private int _firstVisibleIndex;
private int _lastVisibleIndex;
private bool _isDragging;
private bool _isDraggingScrollbar;
private float _dragStartY;
private float _dragStartOffset;
private float _scrollbarDragStartY;
private float _scrollbarDragStartScrollOffset;
private float _scrollbarDragAvailableTrack;
private float _scrollbarDragMaxScroll;
private float _velocity;
private DateTime _lastDragTime;
// Scroll bar
private bool _showVerticalScrollBar = true;
private float _scrollBarWidth = 8;
private Color _scrollBarColor = SkiaTheme.ScrollbarThumb;
private Color _scrollBarTrackColor = SkiaTheme.ScrollbarTrack;
public IEnumerable? ItemsSource
{
get => _itemsSource;
set
{
if (_itemsSource is INotifyCollectionChanged oldCollection)
{
oldCollection.CollectionChanged -= OnCollectionChanged;
}
_itemsSource = value;
RefreshItems();
if (_itemsSource is INotifyCollectionChanged newCollection)
{
newCollection.CollectionChanged += OnCollectionChanged;
}
Invalidate();
}
}
public float ItemHeight
{
get => _itemHeight;
set
{
_itemHeight = value;
Invalidate();
}
}
public float ItemSpacing
{
get => _itemSpacing;
set
{
_itemSpacing = value;
Invalidate();
}
}
public ScrollBarVisibility VerticalScrollBarVisibility { get; set; } = ScrollBarVisibility.Default;
public ScrollBarVisibility HorizontalScrollBarVisibility { get; set; } = ScrollBarVisibility.Never;
public object? EmptyView { get; set; }
public string? EmptyViewText { get; set; } = "No items";
// Item rendering delegate (legacy)
public Func<object, int, SKRect, SKCanvas, SKPaint, bool>? ItemRenderer { get; set; }
// Item view creator - creates SkiaView from data item using DataTemplate
public Func<object, SkiaView?>? ItemViewCreator { get; set; }
// Cache of created item views for virtualization
protected readonly Dictionary<int, SkiaView> _itemViewCache = new();
// Cache of individual item heights for variable height items
protected readonly Dictionary<int, float> _itemHeights = new();
// Track last measured width to clear cache when width changes
private float _lastMeasuredWidth = 0;
// Selection support (overridden in SkiaCollectionView)
public virtual int SelectedIndex { get; set; } = -1;
public event EventHandler<ItemsScrolledEventArgs>? Scrolled;
public event EventHandler<ItemsViewItemTappedEventArgs>? ItemTapped;
public SkiaItemsView()
{
IsFocusable = true;
}
protected virtual void RefreshItems()
{
DiagnosticLog.Debug("SkiaItemsView", $"RefreshItems called, clearing {_items.Count} items and {_itemViewCache.Count} cached views");
_items.Clear();
_itemViewCache.Clear(); // Clear cached views when items change
_itemHeights.Clear(); // Clear cached heights
if (_itemsSource != null)
{
foreach (var item in _itemsSource)
{
_items.Add(item);
}
}
DiagnosticLog.Debug("SkiaItemsView", $"RefreshItems done, now have {_items.Count} items");
_scrollOffset = 0;
}
/// <summary>
/// Called when theme changes to refresh all cached item views.
/// Clears the item view cache so items are recreated with new theme colors.
/// </summary>
public virtual void RefreshTheme()
{
// Clear cached views to force recreation with new AppThemeBinding values
_itemViewCache.Clear();
_itemHeights.Clear();
Invalidate();
}
private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshItems();
Invalidate();
}
/// <summary>
/// Gets the height for a specific item, using cached height or default.
/// Always returns at least ItemHeight to allow vertical centering of smaller content.
/// </summary>
protected float GetItemHeight(int index)
{
var cached = _itemHeights.TryGetValue(index, out var height) ? height : _itemHeight;
return Math.Max(cached, _itemHeight);
}
/// <summary>
/// Gets the Y offset for a specific item (cumulative height of all previous items).
/// </summary>
protected float GetItemOffset(int index)
{
float offset = 0;
for (int i = 0; i < index && i < _items.Count; i++)
{
offset += GetItemHeight(i) + _itemSpacing;
}
return offset;
}
/// <summary>
/// Calculates total content height based on individual item heights.
/// </summary>
protected float TotalContentHeight
{
get
{
if (_items.Count == 0) return 0;
float total = 0;
for (int i = 0; i < _items.Count; i++)
{
total += GetItemHeight(i);
if (i < _items.Count - 1) total += _itemSpacing;
}
return total;
}
}
// Use ScreenBounds.Height for visible viewport
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - (float)ScreenBounds.Height);
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
DiagnosticLog.Debug("SkiaItemsView", $"OnDraw - bounds={bounds}, items={_items.Count}, ItemViewCreator={(ItemViewCreator != null ? "set" : "null")}");
// Draw background
if (BackgroundColor != null && BackgroundColor != Colors.Transparent)
{
using var bgPaint = new SKPaint
{
Color = GetEffectiveBackgroundColor(),
Style = SKPaintStyle.Fill
};
canvas.DrawRect(bounds, bgPaint);
}
// If no items, show empty view
if (_items.Count == 0)
{
DrawEmptyView(canvas, bounds);
return;
}
// Find first visible index by walking through items
_firstVisibleIndex = 0;
float cumulativeOffset = 0;
for (int i = 0; i < _items.Count; i++)
{
var itemH = GetItemHeight(i);
if (cumulativeOffset + itemH > _scrollOffset)
{
_firstVisibleIndex = i;
break;
}
cumulativeOffset += itemH + _itemSpacing;
}
// Clip to bounds
canvas.Save();
canvas.ClipRect(bounds);
// Draw visible items using variable heights
using var paint = new SKPaint
{
IsAntialias = true
};
float currentY = bounds.Top + GetItemOffset(_firstVisibleIndex) - _scrollOffset;
for (int i = _firstVisibleIndex; i < _items.Count; i++)
{
var itemH = GetItemHeight(i);
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), currentY + itemH);
// Stop if we've passed the visible area
if (itemRect.Top > bounds.Bottom)
{
_lastVisibleIndex = i - 1;
break;
}
_lastVisibleIndex = i;
if (itemRect.Bottom >= bounds.Top)
{
DrawItem(canvas, _items[i], i, itemRect, paint);
}
currentY += itemH + _itemSpacing;
}
canvas.Restore();
// Draw scrollbar
if (_showVerticalScrollBar && TotalContentHeight > bounds.Height)
{
DrawScrollBar(canvas, bounds);
}
}
protected virtual void DrawItem(SKCanvas canvas, object item, int index, SKRect bounds, SKPaint paint)
{
// Draw selection highlight
if (index == SelectedIndex)
{
paint.Color = SkiaTheme.PrimarySelectionSK;
paint.Style = SKPaintStyle.Fill;
canvas.DrawRect(bounds, paint);
}
// Try to use ItemViewCreator for templated rendering
if (ItemViewCreator != null)
{
DiagnosticLog.Debug("SkiaItemsView", $"DrawItem {index} - ItemViewCreator exists, item: {item}");
// Get or create cached view for this index
if (!_itemViewCache.TryGetValue(index, out var itemView) || itemView == null)
{
itemView = ItemViewCreator(item);
if (itemView != null)
{
itemView.Parent = this;
_itemViewCache[index] = itemView;
}
}
if (itemView != null)
{
// Measure with large height to get natural size
var availableSize = new Size(bounds.Width, float.MaxValue);
var measuredSize = itemView.Measure(availableSize);
// Store individual item height (with minimum of default height)
var measuredHeight = Math.Max((float)measuredSize.Height, _itemHeight);
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
{
_itemHeights[index] = measuredHeight;
// Request redraw if height changed significantly
if (Math.Abs(cachedHeight - measuredHeight) > 5)
{
Invalidate();
}
}
// Arrange with the actual measured height
var actualBounds = new Rect(bounds.Left, bounds.Top, bounds.Width, measuredHeight);
itemView.Arrange(actualBounds);
itemView.Draw(canvas);
return;
}
}
else
{
DiagnosticLog.Debug("SkiaItemsView", $"DrawItem {index} - ItemViewCreator is NULL, falling back to ToString");
}
// Draw separator
paint.Color = SkiaTheme.Gray300SK;
paint.Style = SKPaintStyle.Stroke;
paint.StrokeWidth = 1;
canvas.DrawLine(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, paint);
// Use custom renderer if provided
if (ItemRenderer != null)
{
if (ItemRenderer(item, index, bounds, canvas, paint))
return;
}
// Default rendering - just show ToString
paint.Color = SkiaTheme.TextPrimarySK;
paint.Style = SKPaintStyle.Fill;
using var font = new SKFont(SKTypeface.Default, 14);
using var textPaint = new SKPaint(font)
{
Color = SkiaTheme.TextPrimarySK,
IsAntialias = true
};
var text = item?.ToString() ?? "";
var textBounds = new SKRect();
textPaint.MeasureText(text, ref textBounds);
var x = bounds.Left + 16;
var y = bounds.MidY - textBounds.MidY;
canvas.DrawText(text, x, y, textPaint);
}
protected virtual void DrawEmptyView(SKCanvas canvas, SKRect bounds)
{
using var paint = new SKPaint
{
Color = SkiaTheme.TextPlaceholderSK,
IsAntialias = true
};
using var font = new SKFont(SKTypeface.Default, 16);
using var textPaint = new SKPaint(font)
{
Color = SkiaTheme.TextPlaceholderSK,
IsAntialias = true
};
var text = EmptyViewText ?? "No items";
var textBounds = new SKRect();
textPaint.MeasureText(text, ref textBounds);
var x = bounds.MidX - textBounds.MidX;
var y = bounds.MidY - textBounds.MidY;
canvas.DrawText(text, x, y, textPaint);
}
private void DrawScrollBar(SKCanvas canvas, SKRect bounds)
{
var trackRect = new SKRect(
bounds.Right - _scrollBarWidth,
bounds.Top,
bounds.Right,
bounds.Bottom);
// Draw track
using var trackPaint = new SKPaint
{
Color = SkiaTheme.ScrollbarTrackSK,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(trackRect, trackPaint);
// Calculate thumb size and position
var viewportRatio = bounds.Height / TotalContentHeight;
var thumbHeight = Math.Max(20, bounds.Height * viewportRatio);
var scrollRatio = _scrollOffset / MaxScrollOffset;
var thumbY = bounds.Top + (bounds.Height - thumbHeight) * scrollRatio;
var thumbRect = new SKRect(
bounds.Right - _scrollBarWidth + 1,
thumbY,
bounds.Right - 1,
thumbY + thumbHeight);
// Draw thumb
using var thumbPaint = new SKPaint
{
Color = SkiaTheme.ScrollbarThumbSK,
Style = SKPaintStyle.Fill,
IsAntialias = true
};
var cornerRadius = (_scrollBarWidth - 2) / 2;
canvas.DrawRoundRect(new SKRoundRect(thumbRect, cornerRadius), thumbPaint);
}
public override void OnPointerPressed(PointerEventArgs e)
{
DiagnosticLog.Debug("SkiaItemsView", $"OnPointerPressed - x={e.X}, y={e.Y}, Bounds={Bounds}, ScreenBounds={ScreenBounds}, ItemCount={_items.Count}");
if (!IsEnabled) return;
// Check if clicking on scrollbar thumb
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
{
var thumbBounds = GetScrollbarThumbBounds();
if (thumbBounds.Contains(e.X, e.Y))
{
_isDraggingScrollbar = true;
_scrollbarDragStartY = e.Y;
_scrollbarDragStartScrollOffset = _scrollOffset;
// Cache values to prevent stutter
var thumbHeight = Math.Max(20f, (float)Bounds.Height * ((float)Bounds.Height / TotalContentHeight));
_scrollbarDragAvailableTrack = (float)Bounds.Height - thumbHeight;
_scrollbarDragMaxScroll = MaxScrollOffset;
return;
}
}
// Regular content drag
_isDragging = true;
_dragStartY = e.Y;
_dragStartOffset = _scrollOffset;
_lastDragTime = DateTime.Now;
_velocity = 0;
}
/// <summary>
/// Gets the bounds of the scrollbar thumb in screen coordinates.
/// </summary>
private SKRect GetScrollbarThumbBounds()
{
// Use ScreenBounds for hit testing (input events use screen coordinates)
var screenBounds = ScreenBounds;
var viewportRatio = (float)screenBounds.Height / TotalContentHeight;
var thumbHeight = Math.Max(20f, (float)screenBounds.Height * viewportRatio);
var scrollRatio = MaxScrollOffset > 0 ? _scrollOffset / MaxScrollOffset : 0f;
var thumbY = (float)screenBounds.Top + ((float)screenBounds.Height - thumbHeight) * scrollRatio;
return new SKRect(
(float)(screenBounds.Left + screenBounds.Width) - _scrollBarWidth,
thumbY,
(float)(screenBounds.Left + screenBounds.Width),
thumbY + thumbHeight);
}
public override void OnPointerMoved(PointerEventArgs e)
{
// Handle scrollbar dragging - use cached values to prevent stutter
if (_isDraggingScrollbar)
{
if (_scrollbarDragAvailableTrack > 0)
{
var deltaY = e.Y - _scrollbarDragStartY;
var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragMaxScroll;
SetScrollOffset(_scrollbarDragStartScrollOffset + scrollDelta);
}
return;
}
if (!_isDragging) return;
var delta = _dragStartY - e.Y;
var newOffset = _dragStartOffset + delta;
// Calculate velocity for momentum scrolling
var now = DateTime.Now;
var timeDelta = (now - _lastDragTime).TotalSeconds;
if (timeDelta > 0)
{
_velocity = (float)((_scrollOffset - newOffset) / timeDelta);
}
_lastDragTime = now;
SetScrollOffset(newOffset);
}
public override void OnPointerReleased(PointerEventArgs e)
{
// Handle scrollbar drag release
if (_isDraggingScrollbar)
{
_isDraggingScrollbar = false;
return;
}
if (_isDragging)
{
_isDragging = false;
// Check for tap (minimal movement)
var totalDrag = Math.Abs(e.Y - _dragStartY);
if (totalDrag < 5)
{
// This was a tap - find which item was tapped using variable heights
var screenBounds = ScreenBounds;
var localY = e.Y - screenBounds.Top + _scrollOffset;
// Find tapped index by walking through item heights
int tappedIndex = -1;
float cumulativeY = 0;
for (int i = 0; i < _items.Count; i++)
{
var itemH = GetItemHeight(i);
if (localY >= cumulativeY && localY < cumulativeY + itemH)
{
tappedIndex = i;
break;
}
cumulativeY += itemH + _itemSpacing;
}
DiagnosticLog.Debug("SkiaItemsView", $"Tap at Y={e.Y}, screenBounds.Top={screenBounds.Top}, scrollOffset={_scrollOffset}, localY={localY}, index={tappedIndex}");
if (tappedIndex >= 0 && tappedIndex < _items.Count)
{
OnItemTapped(tappedIndex, _items[tappedIndex]);
}
}
}
}
/// <summary>
/// Gets the total Y scroll offset from all parent ScrollViews.
/// </summary>
private float GetTotalParentScrollY()
{
float total = 0;
var parent = Parent;
while (parent != null)
{
if (parent is SkiaScrollView scrollView)
{
total += scrollView.ScrollY;
}
parent = parent.Parent;
}
return total;
}
protected virtual void OnItemTapped(int index, object item)
{
SelectedIndex = index;
ItemTapped?.Invoke(this, new ItemsViewItemTappedEventArgs(index, item));
Invalidate();
}
public override void OnScroll(ScrollEventArgs e)
{
var delta = e.DeltaY * 20;
SetScrollOffset(_scrollOffset + delta);
e.Handled = true;
}
private void SetScrollOffset(float offset)
{
var oldOffset = _scrollOffset;
_scrollOffset = Math.Clamp(offset, 0, MaxScrollOffset);
if (Math.Abs(_scrollOffset - oldOffset) > 0.1f)
{
Scrolled?.Invoke(this, new ItemsScrolledEventArgs(_scrollOffset, TotalContentHeight));
Invalidate();
}
}
public void ScrollToIndex(int index, bool animate = true)
{
if (index < 0 || index >= _items.Count) return;
var targetOffset = GetItemOffset(index);
SetScrollOffset(targetOffset);
}
public void ScrollToItem(object item, bool animate = true)
{
var index = _items.IndexOf(item);
if (index >= 0)
{
ScrollToIndex(index, animate);
}
}
public override void OnKeyDown(KeyEventArgs e)
{
if (!IsEnabled) return;
switch (e.Key)
{
case Key.Up:
if (SelectedIndex > 0)
{
SelectedIndex--;
EnsureIndexVisible(SelectedIndex);
Invalidate();
}
e.Handled = true;
break;
case Key.Down:
if (SelectedIndex < _items.Count - 1)
{
SelectedIndex++;
EnsureIndexVisible(SelectedIndex);
Invalidate();
}
e.Handled = true;
break;
case Key.PageUp:
SetScrollOffset(_scrollOffset - (float)Bounds.Height);
e.Handled = true;
break;
case Key.PageDown:
SetScrollOffset(_scrollOffset + (float)Bounds.Height);
e.Handled = true;
break;
case Key.Home:
SelectedIndex = 0;
SetScrollOffset(0);
Invalidate();
e.Handled = true;
break;
case Key.End:
SelectedIndex = _items.Count - 1;
SetScrollOffset(MaxScrollOffset);
Invalidate();
e.Handled = true;
break;
case Key.Enter:
if (SelectedIndex >= 0 && SelectedIndex < _items.Count)
{
OnItemTapped(SelectedIndex, _items[SelectedIndex]);
}
e.Handled = true;
break;
}
}
private void EnsureIndexVisible(int index)
{
var itemTop = GetItemOffset(index);
var itemBottom = itemTop + GetItemHeight(index);
if (itemTop < _scrollOffset)
{
SetScrollOffset(itemTop);
}
else if (itemBottom > _scrollOffset + (float)Bounds.Height)
{
SetScrollOffset(itemBottom - (float)Bounds.Height);
}
}
protected int ItemCount => _items.Count;
protected object? GetItemAt(int index) => index >= 0 && index < _items.Count ? _items[index] : null;
/// <summary>
/// Override HitTest to handle scrollbar clicks properly.
/// HitTest receives content-space coordinates (already transformed by parent ScrollView).
/// </summary>
public override SkiaView? HitTest(float x, float y)
{
// HitTest uses Bounds (content space) - coordinates are transformed by parent
if (!IsVisible || !Bounds.Contains(x, y))
return null;
// Check scrollbar area FIRST before content
// This ensures scrollbar clicks are handled by this view
if (_showVerticalScrollBar && TotalContentHeight > (float)Bounds.Height)
{
var trackArea = new SKRect((float)(Bounds.Left + Bounds.Width) - _scrollBarWidth, (float)Bounds.Top, (float)(Bounds.Left + Bounds.Width), (float)(Bounds.Top + Bounds.Height));
if (trackArea.Contains(x, y))
return this;
}
return this;
}
protected override Size MeasureOverride(Size availableSize)
{
var width = availableSize.Width < double.MaxValue ? availableSize.Width : 200;
var height = availableSize.Height < double.MaxValue ? availableSize.Height : 300;
// Clear item caches when width changes significantly (items need re-measurement for text wrapping)
if (Math.Abs(width - _lastMeasuredWidth) > 5)
{
_itemHeights.Clear();
_itemViewCache.Clear();
_lastMeasuredWidth = (float)width;
}
// Items view takes all available space
return new Size(width, height);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
if (_itemsSource is INotifyCollectionChanged collection)
{
collection.CollectionChanged -= OnCollectionChanged;
}
}
base.Dispose(disposing);
}
/// <summary>
/// Gets the SkiaView for a given item index from the cache.
/// </summary>
public SkiaView? GetItemView(int index)
{
if (!_itemViewCache.TryGetValue(index, out var view))
{
return null;
}
return view;
}
}
/// <summary>
/// Event args for items view scroll events.
/// </summary>
public class ItemsScrolledEventArgs : EventArgs
{
public float ScrollOffset { get; }
public float TotalHeight { get; }
public ItemsScrolledEventArgs(float scrollOffset, float totalHeight)
{
ScrollOffset = scrollOffset;
TotalHeight = totalHeight;
}
}
/// <summary>
/// Event args for items view item tap events.
/// </summary>
public class ItemsViewItemTappedEventArgs : EventArgs
{
public int Index { get; }
public object Item { get; }
public ItemsViewItemTappedEventArgs(int index, object item)
{
Index = index;
Item = item;
}
}