Preview 3: Complete control implementation with XAML data binding
Major milestone adding full control functionality: Controls Enhanced: - Entry/Editor: Full keyboard input, cursor navigation, selection, clipboard - CollectionView: Data binding, selection highlighting, scrolling - CheckBox/Switch/Slider: Interactive state management - Picker/DatePicker/TimePicker: Dropdown selection with popup overlays - ProgressBar/ActivityIndicator: Animated progress display - Button: Press/release visual states - Border/Frame: Rounded corners, stroke styling - Label: Text wrapping, alignment, decorations - Grid/StackLayout: Margin and padding support Features Added: - DisplayAlert dialogs with button actions - NavigationPage with toolbar and back navigation - Shell with flyout menu navigation - XAML value converters for data binding - Margin support in all layout containers - Popup overlay system for pickers New Samples: - TodoApp: Full CRUD task manager with NavigationPage - ShellDemo: Comprehensive control showcase Removed: - ControlGallery (replaced by ShellDemo) - LinuxDemo (replaced by TodoApp) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,13 @@ public class SkiaItemsView : SkiaView
|
||||
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;
|
||||
|
||||
@@ -81,9 +86,21 @@ public class SkiaItemsView : SkiaView
|
||||
public object? EmptyView { get; set; }
|
||||
public string? EmptyViewText { get; set; } = "No items";
|
||||
|
||||
// Item rendering delegate
|
||||
// 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;
|
||||
|
||||
@@ -95,9 +112,12 @@ public class SkiaItemsView : SkiaView
|
||||
IsFocusable = true;
|
||||
}
|
||||
|
||||
private void RefreshItems()
|
||||
protected virtual void RefreshItems()
|
||||
{
|
||||
Console.WriteLine($"[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)
|
||||
@@ -105,6 +125,7 @@ public class SkiaItemsView : SkiaView
|
||||
_items.Add(item);
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"[SkiaItemsView] RefreshItems done, now have {_items.Count} items");
|
||||
_scrollOffset = 0;
|
||||
}
|
||||
|
||||
@@ -114,11 +135,53 @@ public class SkiaItemsView : SkiaView
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
protected float TotalContentHeight => _items.Count * (_itemHeight + _itemSpacing) - _itemSpacing;
|
||||
protected float MaxScrollOffset => Math.Max(0, TotalContentHeight - Bounds.Height);
|
||||
/// <summary>
|
||||
/// Gets the height for a specific item, using cached height or default.
|
||||
/// </summary>
|
||||
protected float GetItemHeight(int index)
|
||||
{
|
||||
return _itemHeights.TryGetValue(index, out var height) ? height : _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 - ScreenBounds.Height);
|
||||
|
||||
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] OnDraw - bounds={bounds}, items={_items.Count}, ItemViewCreator={(ItemViewCreator != null ? "set" : "null")}");
|
||||
|
||||
// Draw background
|
||||
if (BackgroundColor != SKColors.Transparent)
|
||||
{
|
||||
@@ -137,30 +200,51 @@ public class SkiaItemsView : SkiaView
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
_firstVisibleIndex = Math.Max(0, (int)(_scrollOffset / (_itemHeight + _itemSpacing)));
|
||||
_lastVisibleIndex = Math.Min(_items.Count - 1,
|
||||
(int)((_scrollOffset + bounds.Height) / (_itemHeight + _itemSpacing)) + 1);
|
||||
// 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
|
||||
// Draw visible items using variable heights
|
||||
using var paint = new SKPaint
|
||||
{
|
||||
IsAntialias = true
|
||||
};
|
||||
|
||||
for (int i = _firstVisibleIndex; i <= _lastVisibleIndex; i++)
|
||||
float currentY = bounds.Top + GetItemOffset(_firstVisibleIndex) - _scrollOffset;
|
||||
for (int i = _firstVisibleIndex; i < _items.Count; i++)
|
||||
{
|
||||
var itemY = bounds.Top + (i * (_itemHeight + _itemSpacing)) - _scrollOffset;
|
||||
var itemRect = new SKRect(bounds.Left, itemY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), itemY + _itemHeight);
|
||||
var itemH = GetItemHeight(i);
|
||||
var itemRect = new SKRect(bounds.Left, currentY, bounds.Right - (_showVerticalScrollBar ? _scrollBarWidth : 0), currentY + itemH);
|
||||
|
||||
if (itemRect.Bottom < bounds.Top || itemRect.Top > bounds.Bottom)
|
||||
continue;
|
||||
// Stop if we've passed the visible area
|
||||
if (itemRect.Top > bounds.Bottom)
|
||||
{
|
||||
_lastVisibleIndex = i - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
_lastVisibleIndex = i;
|
||||
|
||||
if (itemRect.Bottom >= bounds.Top)
|
||||
{
|
||||
DrawItem(canvas, _items[i], i, itemRect, paint);
|
||||
}
|
||||
|
||||
currentY += itemH + _itemSpacing;
|
||||
}
|
||||
|
||||
canvas.Restore();
|
||||
@@ -177,11 +261,56 @@ public class SkiaItemsView : SkiaView
|
||||
// Draw selection highlight
|
||||
if (index == SelectedIndex)
|
||||
{
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x40); // Light blue
|
||||
paint.Color = new SKColor(0x21, 0x96, 0xF3, 0x59); // Light blue with 35% opacity
|
||||
paint.Style = SKPaintStyle.Fill;
|
||||
canvas.DrawRect(bounds, paint);
|
||||
}
|
||||
|
||||
// Try to use ItemViewCreator for templated rendering
|
||||
if (ItemViewCreator != null)
|
||||
{
|
||||
Console.WriteLine($"[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 SKSize(bounds.Width, float.MaxValue);
|
||||
var measuredSize = itemView.Measure(availableSize);
|
||||
|
||||
// Store individual item height (with minimum of default height)
|
||||
var measuredHeight = Math.Max(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 SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + measuredHeight);
|
||||
itemView.Arrange(actualBounds);
|
||||
itemView.Draw(canvas);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[SkiaItemsView] DrawItem {index} - ItemViewCreator is NULL, falling back to ToString");
|
||||
}
|
||||
|
||||
// Draw separator
|
||||
paint.Color = new SKColor(0xE0, 0xE0, 0xE0);
|
||||
paint.Style = SKPaintStyle.Stroke;
|
||||
@@ -281,8 +410,27 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
public override void OnPointerPressed(PointerEventArgs e)
|
||||
{
|
||||
Console.WriteLine($"[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(20, Bounds.Height * (Bounds.Height / TotalContentHeight));
|
||||
_scrollbarDragAvailableTrack = Bounds.Height - thumbHeight;
|
||||
_scrollbarDragMaxScroll = MaxScrollOffset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular content drag
|
||||
_isDragging = true;
|
||||
_dragStartY = e.Y;
|
||||
_dragStartOffset = _scrollOffset;
|
||||
@@ -290,8 +438,39 @@ public class SkiaItemsView : SkiaView
|
||||
_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 = screenBounds.Height / TotalContentHeight;
|
||||
var thumbHeight = Math.Max(20, screenBounds.Height * viewportRatio);
|
||||
var scrollRatio = MaxScrollOffset > 0 ? _scrollOffset / MaxScrollOffset : 0;
|
||||
var thumbY = screenBounds.Top + (screenBounds.Height - thumbHeight) * scrollRatio;
|
||||
|
||||
return new SKRect(
|
||||
screenBounds.Right - _scrollBarWidth,
|
||||
thumbY,
|
||||
screenBounds.Right,
|
||||
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;
|
||||
@@ -311,6 +490,13 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
public override void OnPointerReleased(PointerEventArgs e)
|
||||
{
|
||||
// Handle scrollbar drag release
|
||||
if (_isDraggingScrollbar)
|
||||
{
|
||||
_isDraggingScrollbar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isDragging)
|
||||
{
|
||||
_isDragging = false;
|
||||
@@ -319,9 +505,25 @@ public class SkiaItemsView : SkiaView
|
||||
var totalDrag = Math.Abs(e.Y - _dragStartY);
|
||||
if (totalDrag < 5)
|
||||
{
|
||||
// This was a tap - find which item was tapped
|
||||
var tapY = e.Y + _scrollOffset - Bounds.Top;
|
||||
var tappedIndex = (int)(tapY / (_itemHeight + _itemSpacing));
|
||||
// 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;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SkiaItemsView] Tap at Y={e.Y}, screenBounds.Top={screenBounds.Top}, scrollOffset={_scrollOffset}, localY={localY}, index={tappedIndex}");
|
||||
|
||||
if (tappedIndex >= 0 && tappedIndex < _items.Count)
|
||||
{
|
||||
@@ -331,6 +533,24 @@ public class SkiaItemsView : SkiaView
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
@@ -361,7 +581,7 @@ public class SkiaItemsView : SkiaView
|
||||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
|
||||
var targetOffset = index * (_itemHeight + _itemSpacing);
|
||||
var targetOffset = GetItemOffset(index);
|
||||
SetScrollOffset(targetOffset);
|
||||
}
|
||||
|
||||
@@ -436,8 +656,8 @@ public class SkiaItemsView : SkiaView
|
||||
|
||||
private void EnsureIndexVisible(int index)
|
||||
{
|
||||
var itemTop = index * (_itemHeight + _itemSpacing);
|
||||
var itemBottom = itemTop + _itemHeight;
|
||||
var itemTop = GetItemOffset(index);
|
||||
var itemBottom = itemTop + GetItemHeight(index);
|
||||
|
||||
if (itemTop < _scrollOffset)
|
||||
{
|
||||
@@ -452,12 +672,43 @@ public class SkiaItemsView : SkiaView
|
||||
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(new SKPoint(x, y)))
|
||||
return null;
|
||||
|
||||
// Check scrollbar area FIRST before content
|
||||
// This ensures scrollbar clicks are handled by this view
|
||||
if (_showVerticalScrollBar && TotalContentHeight > Bounds.Height)
|
||||
{
|
||||
var trackArea = new SKRect(Bounds.Right - _scrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom);
|
||||
if (trackArea.Contains(x, y))
|
||||
return this;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override SKSize MeasureOverride(SKSize availableSize)
|
||||
{
|
||||
var width = availableSize.Width < float.MaxValue ? availableSize.Width : 200;
|
||||
var height = availableSize.Height < float.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 = width;
|
||||
}
|
||||
|
||||
// Items view takes all available space
|
||||
return new SKSize(
|
||||
availableSize.Width < float.MaxValue ? availableSize.Width : 200,
|
||||
availableSize.Height < float.MaxValue ? availableSize.Height : 300);
|
||||
return new SKSize(width, height);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
Reference in New Issue
Block a user