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:
logikonline
2025-12-21 13:26:56 -05:00
parent f945d2a537
commit 1d55ac672a
142 changed files with 38925 additions and 4201 deletions

View File

@@ -6,16 +6,132 @@ using SkiaSharp;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Skia-rendered scroll view container.
/// Skia-rendered scroll view container with full XAML styling support.
/// </summary>
public class SkiaScrollView : SkiaView
{
#region BindableProperties
/// <summary>
/// Bindable property for Orientation.
/// </summary>
public static readonly BindableProperty OrientationProperty =
BindableProperty.Create(
nameof(Orientation),
typeof(ScrollOrientation),
typeof(SkiaScrollView),
ScrollOrientation.Both,
propertyChanged: (b, o, n) => ((SkiaScrollView)b).InvalidateMeasure());
/// <summary>
/// Bindable property for HorizontalScrollBarVisibility.
/// </summary>
public static readonly BindableProperty HorizontalScrollBarVisibilityProperty =
BindableProperty.Create(
nameof(HorizontalScrollBarVisibility),
typeof(ScrollBarVisibility),
typeof(SkiaScrollView),
ScrollBarVisibility.Auto,
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
/// <summary>
/// Bindable property for VerticalScrollBarVisibility.
/// </summary>
public static readonly BindableProperty VerticalScrollBarVisibilityProperty =
BindableProperty.Create(
nameof(VerticalScrollBarVisibility),
typeof(ScrollBarVisibility),
typeof(SkiaScrollView),
ScrollBarVisibility.Auto,
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
/// <summary>
/// Bindable property for ScrollBarColor.
/// </summary>
public static readonly BindableProperty ScrollBarColorProperty =
BindableProperty.Create(
nameof(ScrollBarColor),
typeof(SKColor),
typeof(SkiaScrollView),
new SKColor(0x80, 0x80, 0x80, 0x80),
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
/// <summary>
/// Bindable property for ScrollBarWidth.
/// </summary>
public static readonly BindableProperty ScrollBarWidthProperty =
BindableProperty.Create(
nameof(ScrollBarWidth),
typeof(float),
typeof(SkiaScrollView),
8f,
propertyChanged: (b, o, n) => ((SkiaScrollView)b).Invalidate());
#endregion
#region Properties
/// <summary>
/// Gets or sets the scroll orientation.
/// </summary>
public ScrollOrientation Orientation
{
get => (ScrollOrientation)GetValue(OrientationProperty);
set => SetValue(OrientationProperty, value);
}
/// <summary>
/// Gets or sets whether to show horizontal scrollbar.
/// </summary>
public ScrollBarVisibility HorizontalScrollBarVisibility
{
get => (ScrollBarVisibility)GetValue(HorizontalScrollBarVisibilityProperty);
set => SetValue(HorizontalScrollBarVisibilityProperty, value);
}
/// <summary>
/// Gets or sets whether to show vertical scrollbar.
/// </summary>
public ScrollBarVisibility VerticalScrollBarVisibility
{
get => (ScrollBarVisibility)GetValue(VerticalScrollBarVisibilityProperty);
set => SetValue(VerticalScrollBarVisibilityProperty, value);
}
/// <summary>
/// Scrollbar color.
/// </summary>
public SKColor ScrollBarColor
{
get => (SKColor)GetValue(ScrollBarColorProperty);
set => SetValue(ScrollBarColorProperty, value);
}
/// <summary>
/// Scrollbar width.
/// </summary>
public float ScrollBarWidth
{
get => (float)GetValue(ScrollBarWidthProperty);
set => SetValue(ScrollBarWidthProperty, value);
}
#endregion
private SkiaView? _content;
private float _scrollX;
private float _scrollY;
private float _velocityX;
private float _velocityY;
private bool _isDragging;
private bool _isDraggingVerticalScrollbar;
private bool _isDraggingHorizontalScrollbar;
private float _scrollbarDragStartY;
private float _scrollbarDragStartScrollY;
private float _scrollbarDragStartX;
private float _scrollbarDragStartScrollX;
private float _scrollbarDragAvailableTrack; // Cache to prevent stutter
private float _scrollbarDragScrollableExtent; // Cache to prevent stutter
private float _lastPointerX;
private float _lastPointerY;
@@ -35,14 +151,36 @@ public class SkiaScrollView : SkiaView
_content = value;
if (_content != null)
{
_content.Parent = this;
// Propagate binding context to new content
if (BindingContext != null)
{
SetInheritedBindingContext(_content, BindingContext);
}
}
InvalidateMeasure();
Invalidate();
}
}
}
/// <summary>
/// Called when binding context changes. Propagates to content.
/// </summary>
protected override void OnBindingContextChanged()
{
base.OnBindingContextChanged();
// Propagate binding context to content
if (_content != null)
{
SetInheritedBindingContext(_content, BindingContext);
}
}
/// <summary>
/// Gets or sets the horizontal scroll position.
/// </summary>
@@ -82,43 +220,39 @@ public class SkiaScrollView : SkiaView
/// <summary>
/// Gets the maximum horizontal scroll extent.
/// </summary>
public float ScrollableWidth => Math.Max(0, ContentSize.Width - Bounds.Width);
public float ScrollableWidth
{
get
{
// Handle infinite or NaN bounds - use a reasonable default viewport
var viewportWidth = float.IsInfinity(Bounds.Width) || float.IsNaN(Bounds.Width) || Bounds.Width <= 0
? 800f
: Bounds.Width;
return Math.Max(0, ContentSize.Width - viewportWidth);
}
}
/// <summary>
/// Gets the maximum vertical scroll extent.
/// </summary>
public float ScrollableHeight => Math.Max(0, ContentSize.Height - Bounds.Height);
public float ScrollableHeight
{
get
{
// Handle infinite, NaN, or unreasonably large bounds - use a reasonable default viewport
var boundsHeight = Bounds.Height;
var viewportHeight = (float.IsInfinity(boundsHeight) || float.IsNaN(boundsHeight) || boundsHeight <= 0 || boundsHeight > 10000)
? 544f // Default viewport height (600 - 56 for shell header)
: boundsHeight;
return Math.Max(0, ContentSize.Height - viewportHeight);
}
}
/// <summary>
/// Gets the content size.
/// </summary>
public SKSize ContentSize { get; private set; }
/// <summary>
/// Gets or sets the scroll orientation.
/// </summary>
public ScrollOrientation Orientation { get; set; } = ScrollOrientation.Both;
/// <summary>
/// Gets or sets whether to show horizontal scrollbar.
/// </summary>
public ScrollBarVisibility HorizontalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
/// <summary>
/// Gets or sets whether to show vertical scrollbar.
/// </summary>
public ScrollBarVisibility VerticalScrollBarVisibility { get; set; } = ScrollBarVisibility.Auto;
/// <summary>
/// Scrollbar color.
/// </summary>
public SKColor ScrollBarColor { get; set; } = new SKColor(0x80, 0x80, 0x80, 0x80);
/// <summary>
/// Scrollbar width.
/// </summary>
public float ScrollBarWidth { get; set; } = 8;
/// <summary>
/// Event raised when scroll position changes.
/// </summary>
@@ -133,6 +267,19 @@ public class SkiaScrollView : SkiaView
// Draw content with scroll offset
if (_content != null)
{
// Ensure content is measured and arranged
var availableSize = new SKSize(bounds.Width, float.PositiveInfinity);
_content.Measure(availableSize);
// Apply content's margin
var margin = _content.Margin;
var contentBounds = new SKRect(
bounds.Left + (float)margin.Left,
bounds.Top + (float)margin.Top,
bounds.Left + Math.Max(bounds.Width, _content.DesiredSize.Width) - (float)margin.Right,
bounds.Top + Math.Max(bounds.Height, _content.DesiredSize.Height) - (float)margin.Bottom);
_content.Arrange(contentBounds);
canvas.Save();
canvas.Translate(-_scrollX, -_scrollY);
_content.Draw(canvas);
@@ -233,22 +380,89 @@ public class SkiaScrollView : SkiaView
public override void OnScroll(ScrollEventArgs e)
{
Console.WriteLine($"[SkiaScrollView] OnScroll - DeltaY={e.DeltaY}, ScrollableHeight={ScrollableHeight}, ContentSize={ContentSize}, Bounds={Bounds}");
// Handle mouse wheel scrolling
var deltaMultiplier = 40f; // Scroll speed
bool scrolled = false;
if (Orientation != ScrollOrientation.Horizontal)
if (Orientation != ScrollOrientation.Horizontal && ScrollableHeight > 0)
{
var oldScrollY = _scrollY;
ScrollY += e.DeltaY * deltaMultiplier;
Console.WriteLine($"[SkiaScrollView] ScrollY changed: {oldScrollY} -> {_scrollY}");
if (_scrollY != oldScrollY)
scrolled = true;
}
if (Orientation != ScrollOrientation.Vertical)
if (Orientation != ScrollOrientation.Vertical && ScrollableWidth > 0)
{
var oldScrollX = _scrollX;
ScrollX += e.DeltaX * deltaMultiplier;
if (_scrollX != oldScrollX)
scrolled = true;
}
// Mark as handled so parent scroll views don't also scroll
if (scrolled)
e.Handled = true;
}
public override void OnPointerPressed(PointerEventArgs e)
{
// Check if clicking on vertical scrollbar thumb
if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0)
{
var thumbBounds = GetVerticalScrollbarThumbBounds();
if (thumbBounds.Contains(e.X, e.Y))
{
_isDraggingVerticalScrollbar = true;
_scrollbarDragStartY = e.Y;
_scrollbarDragStartScrollY = _scrollY;
// Cache values to prevent stutter from floating-point recalculations
var hasHorizontal = ShouldShowHorizontalScrollbar();
var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0);
var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight);
_scrollbarDragAvailableTrack = trackHeight - thumbHeight;
_scrollbarDragScrollableExtent = ScrollableHeight;
return;
}
}
// Check if clicking on horizontal scrollbar thumb
if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0)
{
var thumbBounds = GetHorizontalScrollbarThumbBounds();
if (thumbBounds.Contains(e.X, e.Y))
{
_isDraggingHorizontalScrollbar = true;
_scrollbarDragStartX = e.X;
_scrollbarDragStartScrollX = _scrollX;
// Cache values to prevent stutter from floating-point recalculations
var hasVertical = ShouldShowVerticalScrollbar();
var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0);
var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth);
_scrollbarDragAvailableTrack = trackWidth - thumbWidth;
_scrollbarDragScrollableExtent = ScrollableWidth;
return;
}
}
// Forward click to content first
if (_content != null)
{
// Translate coordinates for scroll offset
var contentE = new PointerEventArgs(e.X + _scrollX, e.Y + _scrollY, e.Button);
var hit = _content.HitTest(contentE.X, contentE.Y);
if (hit != null && hit != _content)
{
// A child view was hit - forward the event to it
hit.OnPointerPressed(contentE);
return;
}
}
// Regular content dragging
_isDragging = true;
_lastPointerX = e.X;
_lastPointerY = e.Y;
@@ -258,19 +472,44 @@ public class SkiaScrollView : SkiaView
public override void OnPointerMoved(PointerEventArgs e)
{
// Handle vertical scrollbar dragging - use cached values to prevent stutter
if (_isDraggingVerticalScrollbar)
{
if (_scrollbarDragAvailableTrack > 0)
{
var deltaY = e.Y - _scrollbarDragStartY;
var scrollDelta = (deltaY / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent;
ScrollY = _scrollbarDragStartScrollY + scrollDelta;
}
return;
}
// Handle horizontal scrollbar dragging - use cached values to prevent stutter
if (_isDraggingHorizontalScrollbar)
{
if (_scrollbarDragAvailableTrack > 0)
{
var deltaX = e.X - _scrollbarDragStartX;
var scrollDelta = (deltaX / _scrollbarDragAvailableTrack) * _scrollbarDragScrollableExtent;
ScrollX = _scrollbarDragStartScrollX + scrollDelta;
}
return;
}
// Handle content dragging
if (!_isDragging) return;
var deltaX = _lastPointerX - e.X;
var deltaY = _lastPointerY - e.Y;
var contentDeltaX = _lastPointerX - e.X;
var contentDeltaY = _lastPointerY - e.Y;
_velocityX = deltaX;
_velocityY = deltaY;
_velocityX = contentDeltaX;
_velocityY = contentDeltaY;
if (Orientation != ScrollOrientation.Horizontal)
ScrollY += deltaY;
ScrollY += contentDeltaY;
if (Orientation != ScrollOrientation.Vertical)
ScrollX += deltaX;
ScrollX += contentDeltaX;
_lastPointerX = e.X;
_lastPointerY = e.Y;
@@ -279,14 +518,62 @@ public class SkiaScrollView : SkiaView
public override void OnPointerReleased(PointerEventArgs e)
{
_isDragging = false;
_isDraggingVerticalScrollbar = false;
_isDraggingHorizontalScrollbar = false;
// Momentum scrolling could be added here
}
private SKRect GetVerticalScrollbarThumbBounds()
{
var hasHorizontal = ShouldShowHorizontalScrollbar();
var trackHeight = Bounds.Height - (hasHorizontal ? ScrollBarWidth : 0);
var thumbHeight = Math.Max(20, (Bounds.Height / ContentSize.Height) * trackHeight);
var thumbY = ScrollableHeight > 0 ? (ScrollY / ScrollableHeight) * (trackHeight - thumbHeight) : 0;
return new SKRect(
Bounds.Right - ScrollBarWidth,
Bounds.Top + thumbY,
Bounds.Right,
Bounds.Top + thumbY + thumbHeight);
}
private SKRect GetHorizontalScrollbarThumbBounds()
{
var hasVertical = ShouldShowVerticalScrollbar();
var trackWidth = Bounds.Width - (hasVertical ? ScrollBarWidth : 0);
var thumbWidth = Math.Max(20, (Bounds.Width / ContentSize.Width) * trackWidth);
var thumbX = ScrollableWidth > 0 ? (ScrollX / ScrollableWidth) * (trackWidth - thumbWidth) : 0;
return new SKRect(
Bounds.Left + thumbX,
Bounds.Bottom - ScrollBarWidth,
Bounds.Left + thumbX + thumbWidth,
Bounds.Bottom);
}
public override SkiaView? HitTest(float x, float y)
{
if (!IsVisible || !Bounds.Contains(new SKPoint(x, y)))
if (!IsVisible || !IsEnabled || !Bounds.Contains(new SKPoint(x, y)))
return null;
// Check scrollbar areas FIRST before content
// This ensures scrollbar clicks are handled by the ScrollView, not content underneath
if (ShouldShowVerticalScrollbar() && ScrollableHeight > 0)
{
var thumbBounds = GetVerticalScrollbarThumbBounds();
// Check if click is in the scrollbar track area (not just thumb)
var trackArea = new SKRect(Bounds.Right - ScrollBarWidth, Bounds.Top, Bounds.Right, Bounds.Bottom);
if (trackArea.Contains(x, y))
return this;
}
if (ShouldShowHorizontalScrollbar() && ScrollableWidth > 0)
{
var trackArea = new SKRect(Bounds.Left, Bounds.Bottom - ScrollBarWidth, Bounds.Right, Bounds.Bottom);
if (trackArea.Contains(x, y))
return this;
}
// Hit test content with scroll offset
if (_content != null)
{
@@ -360,35 +647,88 @@ public class SkiaScrollView : SkiaView
{
if (_content != null)
{
// Give content unlimited size in scrollable directions
var contentAvailable = new SKSize(
Orientation == ScrollOrientation.Vertical ? availableSize.Width : float.PositiveInfinity,
Orientation == ScrollOrientation.Horizontal ? availableSize.Height : float.PositiveInfinity);
// For responsive layout:
// - Vertical: give content viewport width, infinite height
// - Horizontal: give content infinite width, viewport height
// - Both: give content viewport width first (for responsive layout),
// but if content exceeds it, horizontal scrollbar appears
// - Neither: give content exact viewport size
ContentSize = _content.Measure(contentAvailable);
float contentWidth, contentHeight;
switch (Orientation)
{
case ScrollOrientation.Horizontal:
contentWidth = float.PositiveInfinity;
contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height;
break;
case ScrollOrientation.Neither:
contentWidth = float.IsInfinity(availableSize.Width) ? 400f : availableSize.Width;
contentHeight = float.IsInfinity(availableSize.Height) ? 400f : availableSize.Height;
break;
case ScrollOrientation.Both:
// For Both: first measure with viewport width to get responsive layout
// Content can still exceed viewport if it has minimum width constraints
contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width;
contentHeight = float.PositiveInfinity;
break;
case ScrollOrientation.Vertical:
default:
contentWidth = float.IsInfinity(availableSize.Width) ? 800f : availableSize.Width;
contentHeight = float.PositiveInfinity;
break;
}
ContentSize = _content.Measure(new SKSize(contentWidth, contentHeight));
}
else
{
ContentSize = SKSize.Empty;
}
return availableSize;
// Return available size, but clamp infinite dimensions
// IMPORTANT: When available is infinite, return a reasonable viewport size, NOT content size
// A ScrollView should NOT expand to fit its content - it should stay at a fixed viewport
// and scroll the content. Use a default viewport size when parent gives infinity.
const float DefaultViewportWidth = 400f;
const float DefaultViewportHeight = 400f;
var width = float.IsInfinity(availableSize.Width) || float.IsNaN(availableSize.Width)
? Math.Min(ContentSize.Width, DefaultViewportWidth)
: availableSize.Width;
var height = float.IsInfinity(availableSize.Height) || float.IsNaN(availableSize.Height)
? Math.Min(ContentSize.Height, DefaultViewportHeight)
: availableSize.Height;
return new SKSize(width, height);
}
protected override SKRect ArrangeOverride(SKRect bounds)
{
// CRITICAL: If bounds has infinite height, use a fixed viewport size
// NOT ContentSize.Height - that would make ScrollableHeight = 0
const float DefaultViewportHeight = 544f; // 600 - 56 for shell header
var actualBounds = bounds;
if (float.IsInfinity(bounds.Height) || float.IsNaN(bounds.Height))
{
Console.WriteLine($"[SkiaScrollView] WARNING: Infinite/NaN height, using default viewport={DefaultViewportHeight}");
actualBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + DefaultViewportHeight);
}
if (_content != null)
{
// Arrange content at its full size, starting from scroll position
// Apply content's margin and arrange content at its full size
var margin = _content.Margin;
var contentBounds = new SKRect(
bounds.Left,
bounds.Top,
bounds.Left + Math.Max(bounds.Width, ContentSize.Width),
bounds.Top + Math.Max(bounds.Height, ContentSize.Height));
actualBounds.Left + (float)margin.Left,
actualBounds.Top + (float)margin.Top,
actualBounds.Left + Math.Max(actualBounds.Width, ContentSize.Width) - (float)margin.Right,
actualBounds.Top + Math.Max(actualBounds.Height, ContentSize.Height) - (float)margin.Bottom);
_content.Arrange(contentBounds);
}
return bounds;
return actualBounds;
}
}