// 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 Microsoft.Maui.Graphics;
namespace Microsoft.Maui.Platform;
///
/// Selection mode for collection views.
///
public enum SkiaSelectionMode
{
None,
Single,
Multiple
}
///
/// Layout orientation for items.
///
public enum ItemsLayoutOrientation
{
Vertical,
Horizontal
}
///
/// Skia-rendered CollectionView with selection, headers, and flexible layouts.
///
public class SkiaCollectionView : SkiaItemsView
{
private SkiaSelectionMode _selectionMode = SkiaSelectionMode.Single;
private object? _selectedItem;
private List _selectedItems = new();
private int _selectedIndex = -1;
// Layout
private ItemsLayoutOrientation _orientation = ItemsLayoutOrientation.Vertical;
private int _spanCount = 1; // For grid layout
private float _itemWidth = 100;
// Header/Footer
private object? _header;
private object? _footer;
private float _headerHeight = 0;
private float _footerHeight = 0;
// Track if heights changed during draw (requires redraw for correct positioning)
private bool _heightsChangedDuringDraw;
// Uses parent's _itemViewCache for virtualization
protected override void RefreshItems()
{
// Clear selection when items change to avoid stale references
_selectedItems.Clear();
_selectedItem = null;
_selectedIndex = -1;
base.RefreshItems();
}
public SkiaSelectionMode SelectionMode
{
get => _selectionMode;
set
{
_selectionMode = value;
if (value == SkiaSelectionMode.None)
{
ClearSelection();
}
else if (value == SkiaSelectionMode.Single && _selectedItems.Count > 1)
{
// Keep only first selected
var first = _selectedItems.FirstOrDefault();
ClearSelection();
if (first != null)
{
SelectItem(first);
}
}
Invalidate();
}
}
public object? SelectedItem
{
get => _selectedItem;
set
{
if (_selectionMode == SkiaSelectionMode.None) return;
ClearSelection();
if (value != null)
{
SelectItem(value);
}
}
}
public IList SelectedItems => _selectedItems.AsReadOnly();
public override int SelectedIndex
{
get => _selectedIndex;
set
{
if (_selectionMode == SkiaSelectionMode.None) return;
var item = GetItemAt(value);
if (item != null)
{
SelectedItem = item;
}
}
}
public ItemsLayoutOrientation Orientation
{
get => _orientation;
set
{
_orientation = value;
Invalidate();
}
}
public int SpanCount
{
get => _spanCount;
set
{
_spanCount = Math.Max(1, value);
Invalidate();
}
}
public float GridItemWidth
{
get => _itemWidth;
set
{
_itemWidth = value;
Invalidate();
}
}
public object? Header
{
get => _header;
set
{
_header = value;
_headerHeight = value != null ? 44 : 0;
Invalidate();
}
}
public object? Footer
{
get => _footer;
set
{
_footer = value;
_footerHeight = value != null ? 44 : 0;
Invalidate();
}
}
public float HeaderHeight
{
get => _headerHeight;
set
{
_headerHeight = value;
Invalidate();
}
}
public float FooterHeight
{
get => _footerHeight;
set
{
_footerHeight = value;
Invalidate();
}
}
public SKColor SelectionColor { get; set; } = new SKColor(0x21, 0x96, 0xF3, 0x59); // 35% opacity
public SKColor HeaderBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
public SKColor FooterBackgroundColor { get; set; } = new SKColor(0xF5, 0xF5, 0xF5);
public event EventHandler? SelectionChanged;
private void SelectItem(object item)
{
if (_selectionMode == SkiaSelectionMode.None) return;
var oldSelectedItems = _selectedItems.ToList();
if (_selectionMode == SkiaSelectionMode.Single)
{
_selectedItems.Clear();
_selectedItems.Add(item);
_selectedItem = item;
// Find index
for (int i = 0; i < ItemCount; i++)
{
if (GetItemAt(i) == item)
{
_selectedIndex = i;
break;
}
}
}
else // Multiple
{
if (_selectedItems.Contains(item))
{
_selectedItems.Remove(item);
if (_selectedItem == item)
{
_selectedItem = _selectedItems.FirstOrDefault();
}
}
else
{
_selectedItems.Add(item);
_selectedItem = item;
}
_selectedIndex = _selectedItem != null ? GetIndexOf(_selectedItem) : -1;
}
SelectionChanged?.Invoke(this, new CollectionSelectionChangedEventArgs(oldSelectedItems, _selectedItems.ToList()));
Invalidate();
}
private int GetIndexOf(object item)
{
for (int i = 0; i < ItemCount; i++)
{
if (GetItemAt(i) == item)
return i;
}
return -1;
}
private void ClearSelection()
{
var oldItems = _selectedItems.ToList();
_selectedItems.Clear();
_selectedItem = null;
_selectedIndex = -1;
if (oldItems.Count > 0)
{
SelectionChanged?.Invoke(this, new CollectionSelectionChangedEventArgs(oldItems, new List()));
}
}
protected override void OnItemTapped(int index, object item)
{
if (_selectionMode != SkiaSelectionMode.None)
{
SelectItem(item);
}
base.OnItemTapped(index, item);
}
protected override void DrawItem(SKCanvas canvas, object item, int index, SKRect bounds, SKPaint paint)
{
bool isSelected = _selectedItems.Contains(item);
// Draw separator (only for vertical list layout)
if (_orientation == ItemsLayoutOrientation.Vertical && _spanCount == 1)
{
paint.Color = new SKColor(0xE0, 0xE0, 0xE0);
paint.Style = SKPaintStyle.Stroke;
paint.StrokeWidth = 1;
canvas.DrawLine(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, paint);
}
// Try to use ItemViewCreator for templated rendering (from DataTemplate)
if (ItemViewCreator != null)
{
// 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)
{
try
{
// Measure with large height to get natural size
var availableSize = new SKSize(bounds.Width, float.MaxValue);
var measuredSize = itemView.Measure(availableSize);
// Cap measured height - if item returns infinity/MaxValue, use ItemHeight as default
// This happens with Star-sized Grids that have no natural height preference
var rawHeight = measuredSize.Height;
if (float.IsNaN(rawHeight) || float.IsInfinity(rawHeight) || rawHeight > 10000)
{
rawHeight = ItemHeight;
}
// Ensure minimum height
var measuredHeight = Math.Max(rawHeight, ItemHeight);
if (!_itemHeights.TryGetValue(index, out var cachedHeight) || Math.Abs(cachedHeight - measuredHeight) > 1)
{
_itemHeights[index] = measuredHeight;
_heightsChangedDuringDraw = true; // Flag for redraw with correct positions
}
// Arrange with the actual measured height
var actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
itemView.Arrange(actualBounds);
itemView.Draw(canvas);
// Draw selection highlight ON TOP of the item content (semi-transparent overlay)
if (isSelected)
{
paint.Color = SelectionColor;
paint.Style = SKPaintStyle.Fill;
canvas.DrawRoundRect(actualBounds, 12, 12, paint);
}
// Draw checkmark for selected items in multiple selection mode
if (isSelected && _selectionMode == SkiaSelectionMode.Multiple)
{
DrawCheckmark(canvas, new SKRect(actualBounds.Right - 32, actualBounds.MidY - 8, actualBounds.Right - 16, actualBounds.MidY + 8));
}
}
catch (Exception ex)
{
Console.WriteLine($"[SkiaCollectionView.DrawItem] EXCEPTION: {ex.Message}\n{ex.StackTrace}");
}
return;
}
}
// Use custom renderer if provided
if (ItemRenderer != null)
{
if (ItemRenderer(item, index, bounds, canvas, paint))
return;
}
// Default rendering - fall back to ToString
paint.Color = SKColors.Black;
paint.Style = SKPaintStyle.Fill;
using var font = new SKFont(SKTypeface.Default, 14);
using var textPaint = new SKPaint(font)
{
Color = SKColors.Black,
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);
// Draw checkmark for selected items in multiple selection mode
if (isSelected && _selectionMode == SkiaSelectionMode.Multiple)
{
DrawCheckmark(canvas, new SKRect(bounds.Right - 32, bounds.MidY - 8, bounds.Right - 16, bounds.MidY + 8));
}
}
private void DrawCheckmark(SKCanvas canvas, SKRect bounds)
{
using var paint = new SKPaint
{
Color = new SKColor(0x21, 0x96, 0xF3),
Style = SKPaintStyle.Stroke,
StrokeWidth = 2,
IsAntialias = true,
StrokeCap = SKStrokeCap.Round
};
using var path = new SKPath();
path.MoveTo(bounds.Left, bounds.MidY);
path.LineTo(bounds.MidX - 2, bounds.Bottom - 2);
path.LineTo(bounds.Right, bounds.Top + 2);
canvas.DrawPath(path, paint);
}
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
// Reset the heights-changed flag at the start of each draw
_heightsChangedDuringDraw = false;
// Draw background if set
if (BackgroundColor != SKColors.Transparent)
{
using var bgPaint = new SKPaint
{
Color = BackgroundColor,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(bounds, bgPaint);
}
// Draw header if present
if (_header != null && _headerHeight > 0)
{
var headerRect = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + _headerHeight);
DrawHeader(canvas, headerRect);
}
// Draw footer if present
if (_footer != null && _footerHeight > 0)
{
var footerRect = new SKRect(bounds.Left, bounds.Bottom - _footerHeight, bounds.Right, bounds.Bottom);
DrawFooter(canvas, footerRect);
}
// Adjust content bounds for header/footer
var contentBounds = new SKRect(
bounds.Left,
bounds.Top + _headerHeight,
bounds.Right,
bounds.Bottom - _footerHeight);
// Draw items
if (ItemCount == 0)
{
DrawEmptyView(canvas, contentBounds);
return;
}
// Use grid layout if spanCount > 1
if (_spanCount > 1)
{
DrawGridItems(canvas, contentBounds);
}
else
{
DrawListItems(canvas, contentBounds);
}
// If heights changed during this draw, schedule a redraw with correct positions
// This will queue another frame to be drawn with the correct cached heights
if (_heightsChangedDuringDraw)
{
_heightsChangedDuringDraw = false;
Invalidate();
}
}
private void DrawListItems(SKCanvas canvas, SKRect bounds)
{
// Standard list drawing with variable item heights
canvas.Save();
canvas.ClipRect(bounds);
using var paint = new SKPaint { IsAntialias = true };
var scrollOffset = GetScrollOffset();
// Find first visible item by walking through items
int firstVisible = 0;
float cumulativeOffset = 0;
for (int i = 0; i < ItemCount; i++)
{
var itemH = GetItemHeight(i);
if (cumulativeOffset + itemH > scrollOffset)
{
firstVisible = i;
break;
}
cumulativeOffset += itemH + ItemSpacing;
}
// Draw visible items using variable heights
float currentY = bounds.Top + GetItemOffset(firstVisible) - scrollOffset;
for (int i = firstVisible; i < ItemCount; i++)
{
var itemH = GetItemHeight(i);
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - 8, currentY + itemH);
// Stop if we've passed the visible area
if (itemRect.Top > bounds.Bottom)
break;
if (itemRect.Bottom >= bounds.Top)
{
var item = GetItemAt(i);
if (item != null)
{
DrawItem(canvas, item, i, itemRect, paint);
}
}
currentY += itemH + ItemSpacing;
}
canvas.Restore();
// Draw scrollbar
var totalHeight = TotalContentHeight;
if (totalHeight > bounds.Height)
{
DrawScrollBarInternal(canvas, bounds, scrollOffset, totalHeight);
}
}
private void DrawGridItems(SKCanvas canvas, SKRect bounds)
{
canvas.Save();
canvas.ClipRect(bounds);
using var paint = new SKPaint { IsAntialias = true };
var cellWidth = (bounds.Width - 8) / _spanCount; // -8 for scrollbar
var cellHeight = ItemHeight;
var rowCount = (int)Math.Ceiling((double)ItemCount / _spanCount);
var totalHeight = rowCount * (cellHeight + ItemSpacing) - ItemSpacing;
var scrollOffset = GetScrollOffset();
var firstVisibleRow = Math.Max(0, (int)(scrollOffset / (cellHeight + ItemSpacing)));
var lastVisibleRow = Math.Min(rowCount - 1,
(int)((scrollOffset + bounds.Height) / (cellHeight + ItemSpacing)) + 1);
for (int row = firstVisibleRow; row <= lastVisibleRow; row++)
{
var rowY = bounds.Top + (row * (cellHeight + ItemSpacing)) - scrollOffset;
for (int col = 0; col < _spanCount; col++)
{
var index = row * _spanCount + col;
if (index >= ItemCount) break;
var cellX = bounds.Left + col * cellWidth;
var cellRect = new SKRect(cellX + 2, rowY, cellX + cellWidth - 2, rowY + cellHeight);
if (cellRect.Bottom < bounds.Top || cellRect.Top > bounds.Bottom)
continue;
var item = GetItemAt(index);
if (item != null)
{
// Draw cell background
using var cellBgPaint = new SKPaint
{
Color = _selectedItems.Contains(item) ? SelectionColor : new SKColor(0xFA, 0xFA, 0xFA),
Style = SKPaintStyle.Fill
};
canvas.DrawRoundRect(new SKRoundRect(cellRect, 4), cellBgPaint);
DrawItem(canvas, item, index, cellRect, paint);
}
}
}
canvas.Restore();
// Draw scrollbar
if (totalHeight > bounds.Height)
{
DrawScrollBarInternal(canvas, bounds, scrollOffset, totalHeight);
}
}
private void DrawScrollBarInternal(SKCanvas canvas, SKRect bounds, float scrollOffset, float totalHeight)
{
var scrollBarWidth = 6f;
var scrollBarMargin = 2f;
// Draw scrollbar track (subtle)
var trackRect = new SKRect(
bounds.Right - scrollBarWidth - scrollBarMargin,
bounds.Top + scrollBarMargin,
bounds.Right - scrollBarMargin,
bounds.Bottom - scrollBarMargin);
using var trackPaint = new SKPaint
{
Color = new SKColor(0, 0, 0, 20),
Style = SKPaintStyle.Fill
};
canvas.DrawRoundRect(new SKRoundRect(trackRect, 3), trackPaint);
// Calculate thumb position and size
var maxOffset = Math.Max(0, totalHeight - bounds.Height);
var viewportRatio = bounds.Height / totalHeight;
var availableTrackHeight = trackRect.Height;
var thumbHeight = Math.Max(30, availableTrackHeight * viewportRatio);
var scrollRatio = maxOffset > 0 ? scrollOffset / maxOffset : 0;
var thumbY = trackRect.Top + (availableTrackHeight - thumbHeight) * scrollRatio;
var thumbRect = new SKRect(
trackRect.Left,
thumbY,
trackRect.Right,
thumbY + thumbHeight);
// Draw thumb with more visible color
using var thumbPaint = new SKPaint
{
Color = new SKColor(100, 100, 100, 180),
Style = SKPaintStyle.Fill,
IsAntialias = true
};
canvas.DrawRoundRect(new SKRoundRect(thumbRect, 3), thumbPaint);
}
private float GetScrollOffset()
{
// Access base class scroll offset through reflection or expose it
// For now, use the field directly through internal access
return _scrollOffset;
}
private void DrawHeader(SKCanvas canvas, SKRect bounds)
{
using var bgPaint = new SKPaint
{
Color = HeaderBackgroundColor,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(bounds, bgPaint);
// Draw header text
var text = _header?.ToString() ?? "";
if (!string.IsNullOrEmpty(text))
{
using var font = new SKFont(SKTypeface.Default, 16);
using var textPaint = new SKPaint(font)
{
Color = SKColors.Black,
IsAntialias = true
};
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);
}
// Draw separator
using var sepPaint = new SKPaint
{
Color = new SKColor(0xE0, 0xE0, 0xE0),
Style = SKPaintStyle.Stroke,
StrokeWidth = 1
};
canvas.DrawLine(bounds.Left, bounds.Bottom, bounds.Right, bounds.Bottom, sepPaint);
}
private void DrawFooter(SKCanvas canvas, SKRect bounds)
{
using var bgPaint = new SKPaint
{
Color = FooterBackgroundColor,
Style = SKPaintStyle.Fill
};
canvas.DrawRect(bounds, bgPaint);
// Draw separator
using var sepPaint = new SKPaint
{
Color = new SKColor(0xE0, 0xE0, 0xE0),
Style = SKPaintStyle.Stroke,
StrokeWidth = 1
};
canvas.DrawLine(bounds.Left, bounds.Top, bounds.Right, bounds.Top, sepPaint);
// Draw footer text
var text = _footer?.ToString() ?? "";
if (!string.IsNullOrEmpty(text))
{
using var font = new SKFont(SKTypeface.Default, 14);
using var textPaint = new SKPaint(font)
{
Color = new SKColor(0x80, 0x80, 0x80),
IsAntialias = true
};
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);
}
}
}
///
/// Event args for collection selection changed events.
///
public class CollectionSelectionChangedEventArgs : EventArgs
{
public IReadOnlyList PreviousSelection { get; }
public IReadOnlyList CurrentSelection { get; }
public CollectionSelectionChangedEventArgs(IList previousSelection, IList currentSelection)
{
PreviousSelection = previousSelection.ToList().AsReadOnly();
CurrentSelection = currentSelection.ToList().AsReadOnly();
}
}