Skip to content

Commit

Permalink
Added outside label style support and more label customisation options.
Browse files Browse the repository at this point in the history
  • Loading branch information
DashTheDev committed Jul 1, 2024
1 parent 5cd3b5d commit 378fe77
Show file tree
Hide file tree
Showing 11 changed files with 256 additions and 54 deletions.
2 changes: 1 addition & 1 deletion Maui.DonutChart.Samples/Services/MockDataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ internal class MockDataService
{
#region Fields

private readonly float _minValue = 0f;
private readonly float _minValue = 15f;
private readonly float _maxValue = 200f;
private readonly int _minResultCount = 1;
private readonly int _maxResultCount = 10;
Expand Down
4 changes: 3 additions & 1 deletion Maui.DonutChart.Samples/Views/SamplePage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
EntryClicked="OnEntryClicked"
EntryColors="{StaticResource ChartColors}"
EntryLabelPath="Category"
EntryValuePath="Score" />
EntryValuePath="Score"
LabelStyle="Outside"
LabelUseAutoFontColor="True" />

<!-- Example without MVVM Entries source -->
<!--<donut:DonutChartView
Expand Down
11 changes: 8 additions & 3 deletions Maui.DonutChart/Constants.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
namespace Maui.DonutChart;
using Maui.DonutChart.Models;

namespace Maui.DonutChart;

internal static class Constants
{
internal static readonly Thickness DefaultPadding = new(10);
internal const float DefaultChartRotationDegrees = 90f;
internal const float DefaultChartOuterRadius = 250f;
internal const float DefaultChartInnerRadius = 125f;
internal const LabelStyle DefaultLabelStyle = LabelStyle.Key;
internal const string DefaultLabelFontFamily = "Arial";
internal static readonly Color DefaultLabelFontColor = Colors.White;
internal const bool DefaultLabelUseAutoFontColor = false;
internal const float DefaultLabelFontSize = 20f;
internal const float DefaultLabelSpacing = 10f;
internal const float DefaultLabelColorOffset = 20f;
internal const float DefaultLabelKeySpacing = 10f;
internal const float DefaultLabelKeyColorOffset = 20f;
internal const float DefaultLabelOutsideRadius = 50f;
internal const string DefaultEntryValuePath = "Value";
internal const string DefaultEntryLabelPath = "Label";
internal static readonly Color[] DefaultChartColors =
Expand Down
208 changes: 167 additions & 41 deletions Maui.DonutChart/Controls/DonutChartView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
using System.Collections.Specialized;
using Maui.DonutChart.Helpers;
using Maui.DonutChart.Models;
using Microsoft.Maui.Controls.Shapes;
using SkiaSharp;
using SkiaSharp.Views.Maui;
using SkiaSharp.Views.Maui.Controls;

namespace Maui.DonutChart.Controls;

/// <summary>
/// A highly customisable <see cref="SKCanvasView"/> tailored to displaying
/// data in a donut segmented chart.
/// </summary>
[ContentProperty(nameof(EntriesSource))]
public class DonutChartView : SKCanvasView, IPadding
{
Expand All @@ -21,7 +24,7 @@ public class DonutChartView : SKCanvasView, IPadding
private InternalDataEntry[] _internalEntries = [];
private SKRect _canvasBounds = SKRect.Empty;
private SKRect _chartBounds = SKRect.Empty;
private SKRect _textBounds = SKRect.Empty;
private SKRect? _textBounds = SKRect.Empty;

#endregion

Expand Down Expand Up @@ -208,6 +211,24 @@ public float ChartInnerRadius
set => SetValue(ChartInnerRadiusProperty, value);
}

/// <summary>Bindable property for <see cref="LabelStyle"/>.</summary>
public static readonly BindableProperty LabelStyleProperty = BindableProperty.Create(
nameof(LabelStyle),
typeof(LabelStyle),
typeof(DonutChartView),
defaultValue: Constants.DefaultLabelStyle,
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets the style to be used for chart labels.<br/><br/>
/// This is a bindable property which defaults to <b><see cref="LabelStyle.Key"/></b>.
/// </summary>
public LabelStyle LabelStyle
{
get => (LabelStyle)GetValue(LabelStyleProperty);
set => SetValue(LabelStyleProperty, value);
}

/// <summary>Bindable property for <see cref="LabelFontColor"/>.</summary>
public static readonly BindableProperty LabelFontFamilyProperty = BindableProperty.Create(
nameof(LabelFontFamily),
Expand Down Expand Up @@ -235,7 +256,8 @@ public string LabelFontFamily
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets the color of the font used for the chart labels.<br/><br/>
/// Gets or sets the color of the font used for the chart labels.<br/>
/// This value will be ignored if <c>LabelUseAutoFontColor</c> is set to <b><see langword="true"/></b>.<br/><br/>
/// This is a bindable property which defaults to <c>White</c>.
/// </summary>
public Color LabelFontColor
Expand All @@ -244,6 +266,24 @@ public Color LabelFontColor
set => SetValue(LabelFontColorProperty, value);
}

/// <summary>Bindable property for <see cref="LabelUseAutoFontColor"/>.</summary>
public static readonly BindableProperty LabelUseAutoFontColorProperty = BindableProperty.Create(
nameof(LabelUseAutoFontColor),
typeof(bool),
typeof(DonutChartView),
defaultValue: Constants.DefaultLabelUseAutoFontColor,
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets if the label font colors will be assigned based on their corresponding entry color.<br/><br/>
/// This is a bindable property which defaults to <b><see langword="false"/></b>.
/// </summary>
public bool LabelUseAutoFontColor
{
get => (bool)GetValue(LabelUseAutoFontColorProperty);
set => SetValue(LabelUseAutoFontColorProperty, value);
}

/// <summary>Bindable property for <see cref="LabelFontSize"/>.</summary>
public static readonly BindableProperty LabelFontSizeProperty = BindableProperty.Create(
nameof(LabelFontSize),
Expand All @@ -262,40 +302,61 @@ public float LabelFontSize
set => SetValue(LabelFontSizeProperty, value);
}

/// <summary>Bindable property for <see cref="LabelSpacing"/>.</summary>
public static readonly BindableProperty LabelSpacingProperty = BindableProperty.Create(
nameof(LabelSpacing),
/// <summary>Bindable property for <see cref="LabelKeySpacing"/>.</summary>
public static readonly BindableProperty LabelKeySpacingProperty = BindableProperty.Create(
nameof(LabelKeySpacing),
typeof(float),
typeof(DonutChartView),
defaultValue: Constants.DefaultLabelSpacing,
defaultValue: Constants.DefaultLabelKeySpacing,
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets the spacing between each chart label.<br/><br/>
/// Gets or sets the spacing between each chart label.<br/>
/// This value is only applied when <c>LabelStyle</c> is set to <b><see cref="LabelStyle.Key"/></b>.<br/><br/>
/// This is a bindable property which defaults to <c>10f</c>.
/// </summary>
public float LabelSpacing
public float LabelKeySpacing
{
get => (float)GetValue(LabelSpacingProperty);
set => SetValue(LabelSpacingProperty, value);
get => (float)GetValue(LabelKeySpacingProperty);
set => SetValue(LabelKeySpacingProperty, value);
}

/// <summary>Bindable property for <see cref="LabelColorOffset"/>.</summary>
public static readonly BindableProperty LabelColorOffsetProperty = BindableProperty.Create(
nameof(LabelColorOffset),
/// <summary>Bindable property for <see cref="LabelKeyColorOffset"/>.</summary>
public static readonly BindableProperty LabelKeyColorOffsetProperty = BindableProperty.Create(
nameof(LabelKeyColorOffset),
typeof(float),
typeof(DonutChartView),
defaultValue: Constants.DefaultLabelColorOffset,
defaultValue: Constants.DefaultLabelKeyColorOffset,
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets the horizontal offset of the color circles rendered next to each label.<br/><br/>
/// Gets or sets the horizontal offset of the color circles rendered next to each label.<br/>
/// This value is only applied when <c>LabelStyle</c> is set to <b><see cref="LabelStyle.Key"/></b>.<br/><br/>
/// This is a bindable property which defaults to <c>20f</c>.
/// </summary>
public float LabelColorOffset
public float LabelKeyColorOffset
{
get => (float)GetValue(LabelColorOffsetProperty);
set => SetValue(LabelColorOffsetProperty, value);
get => (float)GetValue(LabelKeyColorOffsetProperty);
set => SetValue(LabelKeyColorOffsetProperty, value);
}

/// <summary>Bindable property for <see cref="LabelOutsideRadius"/>.</summary>
public static readonly BindableProperty LabelOutsideRadiusProperty = BindableProperty.Create(
nameof(LabelOutsideRadius),
typeof(float),
typeof(DonutChartView),
defaultValue: Constants.DefaultLabelOutsideRadius,
propertyChanged: OnVisualPropertyChanged);

/// <summary>
/// Gets or sets the radius from the <c>ChartOuterRadius</c> where the outside labels will be rendered.<br/>
/// This value is only applied when <c>LabelStyle</c> is set to <b><see cref="LabelStyle.Outside"/></b>.<br/><br/>
/// This is a bindable property which defaults to <c>50f</c>.
/// </summary>
public float LabelOutsideRadius
{
get => (float)GetValue(LabelOutsideRadiusProperty);
set => SetValue(LabelOutsideRadiusProperty, value);
}

#endregion
Expand Down Expand Up @@ -362,7 +423,7 @@ protected override void OnTouch(SKTouchEventArgs e)

foreach (InternalDataEntry entry in _internalEntries)
{
if (entry.Path is not null && entry.Path.Contains(e.Location.X, e.Location.Y))
if (entry.SectorPath is not null && entry.SectorPath.Contains(e.Location.X, e.Location.Y))
{
EntryClicked?.Invoke(this, entry.Value);
}
Expand All @@ -377,16 +438,32 @@ private void RenderChart(SKCanvas canvas, int width, int height)
{
canvas.Clear();

_canvasBounds = new(0, 0, width, height);
_chartBounds = SKGeometry.CreatePaddedRect(0, 0, width * 0.75f, height, Padding);
_textBounds = SKGeometry.CreatePaddedRect(_chartBounds.Width, 0, width, height, Padding);
_internalEntries = ValidateAndPrepareEntries();
_canvasBounds = new(0, 0, width, height);
_chartBounds = CreateChartBounds();
_textBounds = CreateTextBounds();

RenderBackground(canvas);
RenderValues(canvas);
RenderLabels(canvas);
}

private SKRect CreateChartBounds()
{
float width = LabelStyle == LabelStyle.Key ? _canvasBounds.Width * 0.75f : _canvasBounds.Width;
return SKGeometry.CreatePaddedRect(0, 0, width, _canvasBounds.Height, Padding);
}

private SKRect? CreateTextBounds()
{
if (LabelStyle == LabelStyle.Outside)
{
return null;
}

return SKGeometry.CreatePaddedRect(_chartBounds.Width, 0, _canvasBounds.Width, _canvasBounds.Height, Padding);
}

private void RenderBackground(SKCanvas canvas)
{
canvas.DrawRect(_canvasBounds, SKPaints.Fill(BackgroundColor));
Expand All @@ -401,7 +478,7 @@ private void RenderValues(SKCanvas canvas)
return;
}

ColorSelector colorSelector = new(EntryColors);
ColorSelector entryColorSelector = new(EntryColors);
float totalValue = _internalEntries.Sum(a => a.Value);
float percentageFilled = 0.0f;

Expand All @@ -411,52 +488,101 @@ private void RenderValues(SKCanvas canvas)

foreach (InternalDataEntry entry in _internalEntries)
{
SKPaint paint = SKPaints.Fill(colorSelector.Next());
SKPaint paint = SKPaints.Fill(entryColorSelector.Next());

float percentageToFill = entry.Value / totalValue;
float targetPercentageFilled = percentageFilled + percentageToFill;

entry.Path = SKGeometry.CreateSectorPath(_chartBounds.MidX, _chartBounds.MidY, percentageFilled, targetPercentageFilled, outerRadius, innerRadius, ChartRotationDegrees);
canvas.DrawPath(entry.Path, paint);
entry.SectorPath = SKGeometry.CreateSectorPath(_chartBounds.MidX, _chartBounds.MidY, percentageFilled, targetPercentageFilled, outerRadius, innerRadius, ChartRotationDegrees);
canvas.DrawPath(entry.SectorPath, paint);

percentageFilled = targetPercentageFilled;
}
}

// TODO: Add support for changing text positioning
// TODO: Add support for label template replacements
private void RenderLabels(SKCanvas canvas)
{
if (_internalEntries.Length == 0)
{
return;
}

ColorSelector colorSelector = new(EntryColors);
SKPaint textPaint = SKPaints.Text(LabelFontFamily, LabelFontColor, LabelFontSize);
float totalTextHeight = _internalEntries.Length * textPaint.TextSize + (_internalEntries.Length - 1) * LabelSpacing;
ColorSelector entryColorSelector = new(EntryColors);

switch (LabelStyle)
{
case LabelStyle.Key:
RenderKeyLabels(canvas, textPaint, entryColorSelector);
break;

default:
RenderOutsideLabels(canvas, textPaint, entryColorSelector);
break;
};
}

private void RenderKeyLabels(SKCanvas canvas, SKPaint textPaint, ColorSelector entryColorSelector)
{
// Text bounds should be set if LabelStyle is key, but we need to be safe
if (_textBounds is null)
{
return;
}

float totalTextHeight = _internalEntries.Length * textPaint.TextSize + (_internalEntries.Length - 1) * LabelKeySpacing;
float circleRadius = LabelFontSize.Halved();
float circleRadiusHalved = circleRadius.Halved();
float maxWidth = 0;
float maxWidth = _internalEntries
.Select(e => textPaint.MeasureText(e.Label))
.Max();

foreach (InternalDataEntry entry in _internalEntries)
float startX = _textBounds.Value.Left + (_textBounds.Value.Width - maxWidth) / 2 + LabelKeyColorOffset;
float startY = _textBounds.Value.MidY - totalTextHeight / 2;

for (int i = 0; i < _internalEntries.Length; i++)
{
float lineWidth = textPaint.MeasureText(entry.Label);
Color entryColor = entryColorSelector.Next();

if (lineWidth > maxWidth)
if (LabelUseAutoFontColor)
{
maxWidth = lineWidth;
textPaint.SetColor(entryColor);
}

SKPaint circlePaint = SKPaints.Fill(entryColor);
float y = startY + i * (textPaint.TextSize + LabelKeySpacing);
canvas.DrawText(_internalEntries[i].Label, startX, y, textPaint);
canvas.DrawCircle(startX - LabelKeyColorOffset, y - circleRadiusHalved, circleRadius, circlePaint);
}
}

float startX = _textBounds.Left + (_textBounds.Width - maxWidth) / 2 + LabelColorOffset;
float startY = _textBounds.MidY - totalTextHeight / 2;
private void RenderOutsideLabels(SKCanvas canvas, SKPaint textPaint, ColorSelector entryColorSelector)
{
float labelOutsideRadius = ChartOuterRadius + LabelOutsideRadius;

for (int i = 0; i < _internalEntries.Length; i++)
foreach (InternalDataEntry entry in _internalEntries.Where(e => e.SectorPath is not null))
{
SKPaint circlePaint = SKPaints.Fill(colorSelector.Next());
float y = startY + i * (textPaint.TextSize + LabelSpacing);
canvas.DrawText(_internalEntries[i].Label, startX, y, textPaint);
canvas.DrawCircle(startX - LabelColorOffset, y - circleRadiusHalved, circleRadius, circlePaint);
if (LabelUseAutoFontColor)
{
Color entryColor = entryColorSelector.Next();
textPaint.SetColor(entryColor);
}

SKPoint sectorMidpoint = SKGeometry.GetSectorMidpoint(entry.SectorPath!, labelOutsideRadius);

if (_internalEntries.Length == 1)
{
textPaint.TextAlign = SKTextAlign.Center;
}
else
{
bool shouldAlignRight = sectorMidpoint.X - entry.SectorPath!.CenterX < 0;
textPaint.TextAlign = shouldAlignRight ? SKTextAlign.Right : SKTextAlign.Left;
}

canvas.DrawText(entry.Label, sectorMidpoint, textPaint);
}
}

Expand Down
9 changes: 9 additions & 0 deletions Maui.DonutChart/Extensions/SKExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Maui.DonutChart.Helpers;

namespace SkiaSharp;

internal static class SKExtensions
{
internal static void SetColor(this SKPaint paint, Color color)
=> paint.Color = SKPaints.GetSKColor(color);
}
Loading

0 comments on commit 378fe77

Please sign in to comment.