RC6.1 - Question on Implementing Series Visibility Toggle in Custom Legend (WPF) #2061
-
|
Hello, I recently upgraded my Livecharts2 WPF app from rc3.3 to rc5.4, then subsequently to rc6.1. It was painful, but after rewriting my custom legend and tooltip classes to follow the new system implemented in rc5.4, my app is largely functional (thankfully). However, one piece of functionality that I unfortunately lost was the ability to click on a series in the Legend and toggle its visibility on and off. In versions older than rc5.1, the provided path to implement this functionality was subscribing to the PointerDown event of a VisualElement (ex. StackPanel). However, that and many other visuals have been deprecated and replaced with layouts (ex. StackLayout, TableLayout), and layouts don't implement the PointerDown event (only VisualElements do). I consulted both the custom tooltip sample web page (https://livecharts.dev/docs/wpf/2.0.0-rc6.1/samples.general.customLegends), the Livecharts2 wiki from Beto (https://deepwiki.com/beto-rodriguez/LiveCharts2/6.2-legends#2-creating-a-custom-legend), and also searched for recent discussions on this topic; unfortunately, I could not find an applicable answer through these efforts. I tried to workaround this issue by adding empty GeometryVisuals to the chart's VisualElements collection to act as hit-boxes over the StackLayout rows, but I am struggling with the GeometryVisuals not being successfully added to the VisualElements collection during the Measure() method. To be honest, this approach feels really "hacky" and wrong to me, anyway. Is there a new or recommended mechanism in RC6.1 for implementing series visibility toggling within a custom IChartLegend? By the way, I initially thought to use the new VisualElementsPointerDownCommand DependencyProperty, but from my review of the website it appears that the VisualElements collection now only applies to whatever visual elements got added to the chart canvas itself, and has nothing to do with the legend series. Thank you very kindly for any information you can provide! |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment
-
|
After further exploration on the Livecharts2 website and stumbling across the Visibility sampling, it finally dawned on me that I need to create my custom legend in XAML rather than use a C# class that implements IChartLegend. I suppose this is what Beto was trying to inform us in his rc6.1 release notes; my fault for misunderstanding him (apologies Beto! Livecharts2 is friggin awesome!) To that end, I have written both xaml and C# converter classes, as well as a custom Command class that implements ICommand, to build my custom legend using only native UI controls and logic. I have posted my code posted below for review; perhaps it will be of help to someone out there. Please note that my custom legend and tooltip classes, as well as my custom ViewModel, ARE NOT included in this post. One item of critical importance: in your ViewModel code that creates the ISeries instance, be sure to set IsVisibleAtLegend to false so that the ISeries does not get created within SkiaSharp's default legend in memory. I haven't tested this out myself, admittedly, but my suspicion is that if you do not set it to false then "empty space" will be consumed on the CartesianChart by a default legend that contains all your ISeries. I have attached a short video that demonstrates in real-time the toggling of series visibility. As can clearly be seen, this legend perfectly restored my lost functionality, and to be honest improved it because I now no longer need to rely on a custom SkiaSharp class to render it; I can just use pure xaml. XAML UserControl (CartesianChart and ItemsControl)Hopefully, this xaml code is self-explanatory, but I have entered code comments where appropriate. Note that the ItemsControl is now the legend and that my custom ExtendedSkiaSharpLegend class full attribute syntax has been commented out. Again, my ExtendedSkiaSharpLegend class is not included in this post. <UserControl x:Class="YourProject.YourNamespace.YourClassName"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:YourProject.YourNamespace"
xmlns:conv="clr-namespace:YourProject.Converters"
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
xmlns:lvcore="clr-namespace:LiveChartsCore;assembly=LiveChartsCore"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
mc:Ignorable="d" Background="Black"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<conv:PaintToBrushConverter x:Key="PaintToBrushConverter" />
<conv:SvgToGeometryConverter x:Key="SvgToGeometryConverter" />
<Style TargetType="Button" x:Key="LegendButton">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Margin" Value="20 5" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
CornerRadius="4">
<ContentPresenter
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border"
Property="Background"
Value="#22FFFFFF"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="border"
Property="Background"
Value="#44FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<lvc:CartesianChart
x:Name="TrendChart"
Grid.Row="0"
Background="Black"
Series="{Binding Series}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"
DrawMarginFrame="{Binding DrawMarginFrame}"
LegendTextPaint="{Binding LegendTextPaint}"
PointerMoveCommand="{Binding PointerMoveCommand}"
ZoomMode="Both"
LegendPosition="Hidden"
AnimationsSpeed="00:00:02.000"
EasingFunction="{Binding Source={x:Static lvcore:EasingFunctions.BounceOut}}">
<!--<lvc:CartesianChart.Legend>
<custom:ExtendedSkiaSharpLegend />
</lvc:CartesianChart.Legend>-->
<lvc:CartesianChart.Tooltip>
<custom:ExtendedSkiaSharpTooltip />
</lvc:CartesianChart.Tooltip>
</lvc:CartesianChart>
<!-- This will be the new custom chart legend, rendered with pure XAML -->
<ItemsControl
Name="SeriesLegend"
Grid.Row="1"
HorizontalAlignment="Center"
ItemsSource="{Binding Series}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Style>
<Style TargetType="ItemsControl">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding Series.Count}" Value="0">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</ItemsControl.Style>
<ItemsControl.ItemTemplate>
<DataTemplate>
<!--
The button's own DataContext is the ISeries, but the ICommand lives
in the ViewModel, which is the DataContext of ItemsControl. Hence,
the need to relatively bind to the parent ItemsControl to gain access
to the necessary ICommand property of the ViewModel.
CommandParameter={Binding} passes the entire ISeries object into the
ICommand instance's CanExecute and Execute methods.
-->
<Button
Style="{StaticResource LegendButton}"
Command="{Binding RelativeSource={RelativeSource AncestorType=ItemsControl},
Path=DataContext.SeriesToggleCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Path
Data="{Binding GeometrySvg, Converter={StaticResource SvgToGeometryConverter}}"
Fill="{Binding Stroke, Converter={StaticResource PaintToBrushConverter}}"
Height="{Binding ElementName=SeriesText, Path=FontSize}"
Width="{Binding ElementName=SeriesText, Path=FontSize}"
Stretch="Uniform"
VerticalAlignment="Center"
Margin="0,0,8,0" />
<TextBlock
x:Name="SeriesText"
Text="{Binding Name}"
Foreground="White"
FontSize="14"
VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.4"/>
<Setter Property="TextDecorations" Value="Strikethrough"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</UserControl>ConvertersAgain, I hope these converters are self-explanatory. I have tested this code multiple times without failure. For PaintToBrushConverter, I chose the default return SolidColorBrush of white because of my black chart background but choose whichever color suits the project best. For SvgToGeometryConverter, I chose a default Question Mark geometry as my return type in case WPF failed to parse the SVG path, which should NEVER happen as long as the project strictly uses the SVG paths defined in LiveChartsCore or the paths defined in Google's material icons. using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using LiveChartsCore.SkiaSharpView.Painting;
using SkiaSharp;
namespace YourProject.Converters
{
public class PaintToBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is SolidColorPaint paint)
{
SKColor c = paint.Color;
Color color = Color.FromArgb(c.Alpha, c.Red, c.Green, c.Blue);
return new SolidColorBrush(color);
}
return Brushes.White;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
public class SvgToGeometryConverter : IValueConverter
{
// A standard "Question Mark" SVG path string
private static readonly string _questionMarkSvg = "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z";
private static readonly Geometry _questionMark = Geometry.Parse(_questionMarkSvg);
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string svgPath && !string.IsNullOrWhiteSpace(svgPath))
{
try { return Geometry.Parse(svgPath); }
catch { return _questionMark; }
}
return _questionMark;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}Custom CommandI created a separate custom class called LvcSeriesToggleCommand that implements ICommand. Using a RelayCommand in the ViewModel instead is, of course, always an option; my preference is to keep Command logic out of ViewModels, but that's just me. Regardless, make sure the ViewModel has an ICommand property named SeriesToggleCommand. If that's not a good name, then make sure to change it in both the ViewModel and the xaml; otherwise, the binding will break. using LiveChartsCore;
namespace YourProject.Commands
{
public class LvcSeriesToggleCommand : ICommand
{
public event EventHandler? CanExecuteChanged;
private bool _isExecuting;
public bool IsExecuting
{
get { return _isExecuting; }
private set
{
_isExecuting = value;
OnCanExecuteChanged();
}
}
/// <summary>
/// Provides functionality to toggle the visibility of an <see cref="ISeries"/>
/// object in any UI collection on or off
/// </summary>
public LvcSeriesToggleCommand()
{
}
public bool CanExecute(object? parameter)
{
return parameter is not null &&
parameter is ISeries &&
!IsExecuting;
}
public void Execute(object? parameter)
{
if (parameter is not ISeries series) return;
IsExecuting = true;
series.IsVisible = !series.IsVisible;
IsExecuting = false;
}
protected void OnCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, new EventArgs());
}
}
} |
Beta Was this translation helpful? Give feedback.
After further exploration on the Livecharts2 website and stumbling across the Visibility sampling, it finally dawned on me that I need to create my custom legend in XAML rather than use a C# class that implements IChartLegend. I suppose this is what Beto was trying to inform us in his rc6.1 release notes; my fault for misunderstanding him (apologies Beto! Livecharts2 is friggin awesome!)
To that end, I have written both xaml and C# converter classes, as well as a custom Command class that implements ICommand, to build my custom legend using only native UI controls and logic. I have posted my code posted below for review; perhaps it will be of help to someone out there. Please note that my…