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

@@ -7,24 +7,319 @@ using Microsoft.Maui.Platform.Linux.Rendering;
namespace Microsoft.Maui.Platform;
/// <summary>
/// Skia-rendered label control for displaying text.
/// Skia-rendered label control for displaying text with full XAML styling support.
/// </summary>
public class SkiaLabel : SkiaView
{
public string Text { get; set; } = "";
public SKColor TextColor { get; set; } = SKColors.Black;
public string FontFamily { get; set; } = "Sans";
public float FontSize { get; set; } = 14;
public bool IsBold { get; set; }
public bool IsItalic { get; set; }
public bool IsUnderline { get; set; }
public bool IsStrikethrough { get; set; }
public TextAlignment HorizontalTextAlignment { get; set; } = TextAlignment.Start;
public TextAlignment VerticalTextAlignment { get; set; } = TextAlignment.Center;
public LineBreakMode LineBreakMode { get; set; } = LineBreakMode.TailTruncation;
public int MaxLines { get; set; } = 0; // 0 = unlimited
public float LineHeight { get; set; } = 1.2f;
public float CharacterSpacing { get; set; }
#region BindableProperties
/// <summary>
/// Bindable property for Text.
/// </summary>
public static readonly BindableProperty TextProperty =
BindableProperty.Create(
nameof(Text),
typeof(string),
typeof(SkiaLabel),
"",
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for TextColor.
/// </summary>
public static readonly BindableProperty TextColorProperty =
BindableProperty.Create(
nameof(TextColor),
typeof(SKColor),
typeof(SkiaLabel),
SKColors.Black,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for FontFamily.
/// </summary>
public static readonly BindableProperty FontFamilyProperty =
BindableProperty.Create(
nameof(FontFamily),
typeof(string),
typeof(SkiaLabel),
"Sans",
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for FontSize.
/// </summary>
public static readonly BindableProperty FontSizeProperty =
BindableProperty.Create(
nameof(FontSize),
typeof(float),
typeof(SkiaLabel),
14f,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for IsBold.
/// </summary>
public static readonly BindableProperty IsBoldProperty =
BindableProperty.Create(
nameof(IsBold),
typeof(bool),
typeof(SkiaLabel),
false,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for IsItalic.
/// </summary>
public static readonly BindableProperty IsItalicProperty =
BindableProperty.Create(
nameof(IsItalic),
typeof(bool),
typeof(SkiaLabel),
false,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnFontChanged());
/// <summary>
/// Bindable property for IsUnderline.
/// </summary>
public static readonly BindableProperty IsUnderlineProperty =
BindableProperty.Create(
nameof(IsUnderline),
typeof(bool),
typeof(SkiaLabel),
false,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for IsStrikethrough.
/// </summary>
public static readonly BindableProperty IsStrikethroughProperty =
BindableProperty.Create(
nameof(IsStrikethrough),
typeof(bool),
typeof(SkiaLabel),
false,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for HorizontalTextAlignment.
/// </summary>
public static readonly BindableProperty HorizontalTextAlignmentProperty =
BindableProperty.Create(
nameof(HorizontalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Start,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for VerticalTextAlignment.
/// </summary>
public static readonly BindableProperty VerticalTextAlignmentProperty =
BindableProperty.Create(
nameof(VerticalTextAlignment),
typeof(TextAlignment),
typeof(SkiaLabel),
TextAlignment.Center,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for LineBreakMode.
/// </summary>
public static readonly BindableProperty LineBreakModeProperty =
BindableProperty.Create(
nameof(LineBreakMode),
typeof(LineBreakMode),
typeof(SkiaLabel),
LineBreakMode.TailTruncation,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for MaxLines.
/// </summary>
public static readonly BindableProperty MaxLinesProperty =
BindableProperty.Create(
nameof(MaxLines),
typeof(int),
typeof(SkiaLabel),
0,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for LineHeight.
/// </summary>
public static readonly BindableProperty LineHeightProperty =
BindableProperty.Create(
nameof(LineHeight),
typeof(float),
typeof(SkiaLabel),
1.2f,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
/// <summary>
/// Bindable property for CharacterSpacing.
/// </summary>
public static readonly BindableProperty CharacterSpacingProperty =
BindableProperty.Create(
nameof(CharacterSpacing),
typeof(float),
typeof(SkiaLabel),
0f,
propertyChanged: (b, o, n) => ((SkiaLabel)b).Invalidate());
/// <summary>
/// Bindable property for Padding.
/// </summary>
public static readonly BindableProperty PaddingProperty =
BindableProperty.Create(
nameof(Padding),
typeof(SKRect),
typeof(SkiaLabel),
SKRect.Empty,
propertyChanged: (b, o, n) => ((SkiaLabel)b).OnTextChanged());
#endregion
#region Properties
/// <summary>
/// Gets or sets the text content.
/// </summary>
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
/// <summary>
/// Gets or sets the text color.
/// </summary>
public SKColor TextColor
{
get => (SKColor)GetValue(TextColorProperty);
set => SetValue(TextColorProperty, value);
}
/// <summary>
/// Gets or sets the font family.
/// </summary>
public string FontFamily
{
get => (string)GetValue(FontFamilyProperty);
set => SetValue(FontFamilyProperty, value);
}
/// <summary>
/// Gets or sets the font size.
/// </summary>
public float FontSize
{
get => (float)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
/// <summary>
/// Gets or sets whether the text is bold.
/// </summary>
public bool IsBold
{
get => (bool)GetValue(IsBoldProperty);
set => SetValue(IsBoldProperty, value);
}
/// <summary>
/// Gets or sets whether the text is italic.
/// </summary>
public bool IsItalic
{
get => (bool)GetValue(IsItalicProperty);
set => SetValue(IsItalicProperty, value);
}
/// <summary>
/// Gets or sets whether the text has underline.
/// </summary>
public bool IsUnderline
{
get => (bool)GetValue(IsUnderlineProperty);
set => SetValue(IsUnderlineProperty, value);
}
/// <summary>
/// Gets or sets whether the text has strikethrough.
/// </summary>
public bool IsStrikethrough
{
get => (bool)GetValue(IsStrikethroughProperty);
set => SetValue(IsStrikethroughProperty, value);
}
/// <summary>
/// Gets or sets the horizontal text alignment.
/// </summary>
public TextAlignment HorizontalTextAlignment
{
get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty);
set => SetValue(HorizontalTextAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the vertical text alignment.
/// </summary>
public TextAlignment VerticalTextAlignment
{
get => (TextAlignment)GetValue(VerticalTextAlignmentProperty);
set => SetValue(VerticalTextAlignmentProperty, value);
}
/// <summary>
/// Gets or sets the line break mode.
/// </summary>
public LineBreakMode LineBreakMode
{
get => (LineBreakMode)GetValue(LineBreakModeProperty);
set => SetValue(LineBreakModeProperty, value);
}
/// <summary>
/// Gets or sets the maximum number of lines. 0 = unlimited.
/// </summary>
public int MaxLines
{
get => (int)GetValue(MaxLinesProperty);
set => SetValue(MaxLinesProperty, value);
}
/// <summary>
/// Gets or sets the line height multiplier.
/// </summary>
public float LineHeight
{
get => (float)GetValue(LineHeightProperty);
set => SetValue(LineHeightProperty, value);
}
/// <summary>
/// Gets or sets the character spacing.
/// </summary>
public float CharacterSpacing
{
get => (float)GetValue(CharacterSpacingProperty);
set => SetValue(CharacterSpacingProperty, value);
}
/// <summary>
/// Gets or sets the padding.
/// </summary>
public SKRect Padding
{
get => (SKRect)GetValue(PaddingProperty);
set => SetValue(PaddingProperty, value);
}
/// <summary>
/// Gets or sets the horizontal alignment (compatibility property).
/// </summary>
public SkiaTextAlignment HorizontalAlignment
{
get => HorizontalTextAlignment switch
@@ -42,6 +337,10 @@ public class SkiaLabel : SkiaView
_ => TextAlignment.Start
};
}
/// <summary>
/// Gets or sets the vertical alignment (compatibility property).
/// </summary>
public SkiaVerticalAlignment VerticalAlignment
{
get => VerticalTextAlignment switch
@@ -59,7 +358,45 @@ public class SkiaLabel : SkiaView
_ => TextAlignment.Start
};
}
public SKRect Padding { get; set; } = new SKRect(0, 0, 0, 0);
#endregion
private static SKTypeface? _cachedTypeface;
private void OnTextChanged()
{
InvalidateMeasure();
Invalidate();
}
private void OnFontChanged()
{
InvalidateMeasure();
Invalidate();
}
private static SKTypeface GetLinuxTypeface()
{
if (_cachedTypeface != null) return _cachedTypeface;
// Try common Linux font paths
string[] fontPaths = {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf"
};
foreach (var path in fontPaths)
{
if (System.IO.File.Exists(path))
{
_cachedTypeface = SKTypeface.FromFile(path);
if (_cachedTypeface != null) return _cachedTypeface;
}
}
return SKTypeface.Default;
}
protected override void OnDraw(SKCanvas canvas, SKRect bounds)
{
@@ -71,8 +408,11 @@ public class SkiaLabel : SkiaView
SKFontStyleWidth.Normal,
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
?? SKTypeface.Default;
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
if (typeface == null || typeface == SKTypeface.Default)
{
typeface = GetLinuxTypeface();
}
using var font = new SKFont(typeface, FontSize);
using var paint = new SKPaint(font)
@@ -89,13 +429,16 @@ public class SkiaLabel : SkiaView
bounds.Bottom - Padding.Bottom);
// Handle single line vs multiline
if (MaxLines == 1 || !Text.Contains('\n'))
// Use DrawSingleLine for normal labels (MaxLines <= 1 or unlimited) without newlines
// Use DrawMultiLineWithWrapping only when MaxLines > 1 (word wrap needed) or text has newlines
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n');
if (needsMultiLine)
{
DrawSingleLine(canvas, paint, font, contentBounds);
DrawMultiLineWithWrapping(canvas, paint, font, contentBounds);
}
else
{
DrawMultiLine(canvas, paint, font, contentBounds);
DrawSingleLine(canvas, paint, font, contentBounds);
}
}
@@ -160,10 +503,140 @@ public class SkiaLabel : SkiaView
}
}
private void DrawMultiLineWithWrapping(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
{
// Handle inverted or zero-height/width bounds
var effectiveBounds = bounds;
// Fix invalid height
if (bounds.Height <= 0)
{
var effectiveLH = LineHeight <= 0 ? 1.2f : LineHeight;
var estimatedHeight = MaxLines > 0 ? MaxLines * FontSize * effectiveLH : FontSize * effectiveLH * 10;
effectiveBounds = new SKRect(bounds.Left, bounds.Top, bounds.Right, bounds.Top + estimatedHeight);
}
// Fix invalid width - use a reasonable default if width is invalid or extremely large
float effectiveWidth = effectiveBounds.Width;
if (effectiveWidth <= 0)
{
// Use a default width based on canvas
effectiveWidth = 400; // Reasonable default
}
// Note: Previously had width capping logic here that reduced effective width
// to 60% for multiline labels. Removed - the layout system should now provide
// correct widths, and artificially capping causes text to wrap too early.
// First, word-wrap the text to fit within bounds
var wrappedLines = WrapText(paint, Text, effectiveWidth);
// LineHeight of -1 or <= 0 means "use default" - use 1.2 as default multiplier
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
var lineSpacing = FontSize * effectiveLineHeight;
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
// Calculate total height
var totalHeight = maxLinesToDraw * lineSpacing;
// Calculate starting Y based on vertical alignment
float startY = VerticalTextAlignment switch
{
TextAlignment.Start => effectiveBounds.Top + FontSize,
TextAlignment.Center => effectiveBounds.MidY - totalHeight / 2 + FontSize,
TextAlignment.End => effectiveBounds.Bottom - totalHeight + FontSize,
_ => effectiveBounds.Top + FontSize
};
for (int i = 0; i < maxLinesToDraw; i++)
{
var line = wrappedLines[i];
// Add ellipsis if this is the last line and there are more lines
bool isLastLine = i == maxLinesToDraw - 1;
bool hasMoreContent = maxLinesToDraw < wrappedLines.Count;
if (isLastLine && hasMoreContent && LineBreakMode == LineBreakMode.TailTruncation)
{
line = TruncateTextWithEllipsis(paint, line, effectiveWidth);
}
var textBounds = new SKRect();
paint.MeasureText(line, ref textBounds);
float x = HorizontalTextAlignment switch
{
TextAlignment.Start => effectiveBounds.Left,
TextAlignment.Center => effectiveBounds.MidX - textBounds.Width / 2,
TextAlignment.End => effectiveBounds.Right - textBounds.Width,
_ => effectiveBounds.Left
};
float y = startY + i * lineSpacing;
// Don't break early for inverted bounds - just draw
if (effectiveBounds.Height > 0 && y > effectiveBounds.Bottom)
break;
canvas.DrawText(line, x, y, paint);
}
}
private List<string> WrapText(SKPaint paint, string text, float maxWidth)
{
var result = new List<string>();
// Split by newlines first
var paragraphs = text.Split('\n');
foreach (var paragraph in paragraphs)
{
if (string.IsNullOrEmpty(paragraph))
{
result.Add("");
continue;
}
// Check if paragraph fits in one line
if (paint.MeasureText(paragraph) <= maxWidth)
{
result.Add(paragraph);
continue;
}
// Word wrap this paragraph
var words = paragraph.Split(' ');
var currentLine = "";
foreach (var word in words)
{
var testLine = string.IsNullOrEmpty(currentLine) ? word : currentLine + " " + word;
var lineWidth = paint.MeasureText(testLine);
if (lineWidth > maxWidth && !string.IsNullOrEmpty(currentLine))
{
result.Add(currentLine);
currentLine = word;
}
else
{
currentLine = testLine;
}
}
if (!string.IsNullOrEmpty(currentLine))
{
result.Add(currentLine);
}
}
return result;
}
private void DrawMultiLine(SKCanvas canvas, SKPaint paint, SKFont font, SKRect bounds)
{
var lines = Text.Split('\n');
var lineSpacing = FontSize * LineHeight;
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
var lineSpacing = FontSize * effectiveLineHeight;
var maxLinesToDraw = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
// Calculate total height
@@ -208,6 +681,42 @@ public class SkiaLabel : SkiaView
}
}
/// <summary>
/// Truncates text and ALWAYS adds ellipsis (used when there's more content to indicate).
/// </summary>
private string TruncateTextWithEllipsis(SKPaint paint, string text, float maxWidth)
{
const string ellipsis = "...";
var ellipsisWidth = paint.MeasureText(ellipsis);
var textWidth = paint.MeasureText(text);
// If text + ellipsis fits, just append ellipsis
if (textWidth + ellipsisWidth <= maxWidth)
return text + ellipsis;
// Otherwise, truncate to make room for ellipsis
var availableWidth = maxWidth - ellipsisWidth;
if (availableWidth <= 0)
return ellipsis;
// Binary search for the right length
int low = 0;
int high = text.Length;
while (low < high)
{
int mid = (low + high + 1) / 2;
var substring = text.Substring(0, mid);
if (paint.MeasureText(substring) <= availableWidth)
low = mid;
else
high = mid - 1;
}
return text.Substring(0, low) + ellipsis;
}
private string TruncateText(SKPaint paint, string text, float maxWidth)
{
const string ellipsis = "...";
@@ -252,33 +761,51 @@ public class SkiaLabel : SkiaView
SKFontStyleWidth.Normal,
IsItalic ? SKFontStyleSlant.Italic : SKFontStyleSlant.Upright);
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle)
?? SKTypeface.Default;
// Use same typeface logic as OnDraw to ensure consistent measurement
var typeface = SkiaRenderingEngine.Current?.ResourceCache.GetTypeface(FontFamily, fontStyle);
if (typeface == null || typeface == SKTypeface.Default)
{
typeface = GetLinuxTypeface();
}
using var font = new SKFont(typeface, FontSize);
using var paint = new SKPaint(font);
if (MaxLines == 1 || !Text.Contains('\n'))
// Use same logic as OnDraw: multiline only when MaxLines > 1 or text has newlines
bool needsMultiLine = MaxLines > 1 || Text.Contains('\n');
if (!needsMultiLine)
{
var textBounds = new SKRect();
paint.MeasureText(Text, ref textBounds);
// Add small buffer for font rendering tolerance
const float widthBuffer = 4f;
return new SKSize(
textBounds.Width + Padding.Left + Padding.Right,
textBounds.Width + Padding.Left + Padding.Right + widthBuffer,
textBounds.Height + Padding.Top + Padding.Bottom);
}
else
{
var lines = Text.Split('\n');
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, lines.Length) : lines.Length;
// Use available width for word wrapping measurement
var wrapWidth = availableSize.Width - Padding.Left - Padding.Right;
if (wrapWidth <= 0)
{
wrapWidth = float.MaxValue; // No wrapping if no width constraint
}
// Wrap text to get actual line count
var wrappedLines = WrapText(paint, Text, wrapWidth);
var maxLinesToMeasure = MaxLines > 0 ? Math.Min(MaxLines, wrappedLines.Count) : wrappedLines.Count;
float maxWidth = 0;
foreach (var line in lines.Take(maxLinesToMeasure))
foreach (var line in wrappedLines.Take(maxLinesToMeasure))
{
maxWidth = Math.Max(maxWidth, paint.MeasureText(line));
}
var totalHeight = maxLinesToMeasure * FontSize * LineHeight;
var effectiveLineHeight = LineHeight <= 0 ? 1.2f : LineHeight;
var totalHeight = maxLinesToMeasure * FontSize * effectiveLineHeight;
return new SKSize(
maxWidth + Padding.Left + Padding.Right,