Files

352 lines
12 KiB
C#

using MarketAlly.SpotlightTour.Maui.Animations;
namespace MarketAlly.SpotlightTour.Maui;
/// <summary>
/// A simple label that displays title/description inline near the spotlight.
/// Used in SpotlightWithInlineLabel mode.
/// </summary>
public class InlineLabel : Border
{
private readonly Label _titleLabel;
private readonly Label _descriptionLabel;
#region Bindable Properties
public static readonly BindableProperty TitleProperty =
BindableProperty.Create(nameof(Title), typeof(string), typeof(InlineLabel), null,
propertyChanged: (b, _, n) => ((InlineLabel)b)._titleLabel.Text = (string?)n);
public string? Title
{
get => (string?)GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public static readonly BindableProperty DescriptionProperty =
BindableProperty.Create(nameof(Description), typeof(string), typeof(InlineLabel), null,
propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.Text = (string?)n);
public string? Description
{
get => (string?)GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public static readonly BindableProperty ShowDescriptionProperty =
BindableProperty.Create(nameof(ShowDescription), typeof(bool), typeof(InlineLabel), true,
propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.IsVisible = (bool)n);
public bool ShowDescription
{
get => (bool)GetValue(ShowDescriptionProperty);
set => SetValue(ShowDescriptionProperty, value);
}
public static readonly BindableProperty LabelBackgroundColorProperty =
BindableProperty.Create(nameof(LabelBackgroundColor), typeof(Color), typeof(InlineLabel), Colors.White,
propertyChanged: (b, _, n) => ((InlineLabel)b).BackgroundColor = (Color)n);
public Color LabelBackgroundColor
{
get => (Color)GetValue(LabelBackgroundColorProperty);
set => SetValue(LabelBackgroundColorProperty, value);
}
public static readonly BindableProperty TitleColorProperty =
BindableProperty.Create(nameof(TitleColor), typeof(Color), typeof(InlineLabel), Colors.Black,
propertyChanged: (b, _, n) => ((InlineLabel)b)._titleLabel.TextColor = (Color)n);
public Color TitleColor
{
get => (Color)GetValue(TitleColorProperty);
set => SetValue(TitleColorProperty, value);
}
public static readonly BindableProperty DescriptionColorProperty =
BindableProperty.Create(nameof(DescriptionColor), typeof(Color), typeof(InlineLabel), Colors.DarkGray,
propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.TextColor = (Color)n);
public Color DescriptionColor
{
get => (Color)GetValue(DescriptionColorProperty);
set => SetValue(DescriptionColorProperty, value);
}
#endregion
public InlineLabel() : this(TourConfiguration.Default) { }
public InlineLabel(TourConfiguration config)
{
ArgumentNullException.ThrowIfNull(config);
BackgroundColor = Colors.White;
StrokeShape = new Microsoft.Maui.Controls.Shapes.RoundRectangle { CornerRadius = (float)config.DefaultSpotlightCornerRadius };
Stroke = Colors.Transparent;
Padding = new Thickness(12, 8);
HorizontalOptions = LayoutOptions.Start;
VerticalOptions = LayoutOptions.Start;
MaximumWidthRequest = config.InlineLabelMaxWidth;
Shadow = new Shadow
{
Brush = new SolidColorBrush(Colors.Black),
Offset = new Point(0, 2),
Radius = 4,
Opacity = 0.25f
};
_titleLabel = new Label
{
FontAttributes = FontAttributes.Bold,
FontSize = 16,
TextColor = Colors.Black,
LineBreakMode = LineBreakMode.WordWrap
};
_descriptionLabel = new Label
{
FontSize = 13,
TextColor = Colors.DarkGray,
LineBreakMode = LineBreakMode.WordWrap
};
Content = new VerticalStackLayout
{
Spacing = 4,
Children = { _titleLabel, _descriptionLabel }
};
}
/// <summary>
/// Updates the label content.
/// </summary>
public void SetContent(string? title, string? description)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => SetContent(title, description));
return;
}
Title = title;
Description = description;
_titleLabel.IsVisible = !string.IsNullOrWhiteSpace(title);
_descriptionLabel.IsVisible = ShowDescription && !string.IsNullOrWhiteSpace(description);
}
/// <summary>
/// Positions the label relative to a spotlight rectangle.
/// </summary>
public void PositionRelativeToSpotlight(
Rect spotlightRect,
Size containerSize,
CalloutPlacement placement,
double margin = 8)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => PositionRelativeToSpotlight(spotlightRect, containerSize, placement, margin));
return;
}
var effectivePlacement = placement == CalloutPlacement.Auto
? DetermineAutoPlacement(spotlightRect, containerSize, margin)
: placement;
double x, y;
switch (effectivePlacement)
{
case CalloutPlacement.Top:
x = Math.Max(margin, Math.Min(spotlightRect.Center.X - 100, containerSize.Width - 200 - margin));
y = Math.Max(margin, spotlightRect.Top - 60 - margin);
break;
case CalloutPlacement.Bottom:
x = Math.Max(margin, Math.Min(spotlightRect.Center.X - 100, containerSize.Width - 200 - margin));
y = Math.Min(containerSize.Height - 60 - margin, spotlightRect.Bottom + margin);
break;
case CalloutPlacement.Left:
x = Math.Max(margin, spotlightRect.Left - 200 - margin);
y = Math.Max(margin, Math.Min(spotlightRect.Center.Y - 30, containerSize.Height - 60 - margin));
break;
case CalloutPlacement.Right:
x = Math.Min(containerSize.Width - 200 - margin, spotlightRect.Right + margin);
y = Math.Max(margin, Math.Min(spotlightRect.Center.Y - 30, containerSize.Height - 60 - margin));
break;
default:
x = spotlightRect.Center.X - 100;
y = spotlightRect.Bottom + margin;
break;
}
TranslationX = x;
TranslationY = y;
}
private static CalloutPlacement DetermineAutoPlacement(Rect spotlightRect, Size containerSize, double margin)
{
return SpotlightGeometry.DetermineInlineLabelPlacement(spotlightRect, containerSize, margin);
}
// For deferred corner positioning
private CalloutCorner? _pendingCorner;
private double _pendingMargin = 16;
/// <summary>
/// Positions the label in a specific screen corner.
/// </summary>
public void PositionInCorner(CalloutCorner corner, Size containerSize, double margin = 16)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => PositionInCorner(corner, containerSize, margin));
return;
}
// Guard against invalid container size - defer positioning
if (containerSize.Width <= 0 || containerSize.Height <= 0)
{
_pendingCorner = corner;
_pendingMargin = margin;
return;
}
var measured = Measure(MaximumWidthRequest, double.PositiveInfinity);
var labelWidth = Math.Min(measured.Width, MaximumWidthRequest);
var labelHeight = Math.Max(measured.Height, 40); // Ensure minimum height
double x, y;
switch (corner)
{
case CalloutCorner.TopLeft:
x = margin;
y = margin;
break;
case CalloutCorner.TopRight:
x = containerSize.Width - labelWidth - margin;
y = margin;
break;
case CalloutCorner.BottomLeft:
x = margin;
y = containerSize.Height - labelHeight - margin;
break;
case CalloutCorner.BottomRight:
default:
x = containerSize.Width - labelWidth - margin;
y = containerSize.Height - labelHeight - margin;
break;
}
TranslationX = x;
TranslationY = y;
}
/// <summary>
/// Applies any pending corner position when container size becomes available.
/// </summary>
public void ApplyPendingCornerPosition(Size containerSize)
{
if (_pendingCorner.HasValue && containerSize.Width > 0 && containerSize.Height > 0)
{
var corner = _pendingCorner.Value;
var margin = _pendingMargin;
_pendingCorner = null;
PositionInCorner(corner, containerSize, margin);
}
}
/// <summary>
/// Determines the best corner that doesn't overlap with the spotlight area.
/// </summary>
public static CalloutCorner DetermineBestCorner(Rect spotlightRect, Size containerSize, double labelWidth, double labelHeight, double margin = 16)
{
return SpotlightGeometry.DetermineBestCorner(spotlightRect, containerSize, labelWidth, labelHeight, margin);
}
/// <summary>
/// Applies light or dark theme to the inline label.
/// </summary>
public void ApplyTheme(bool isDark)
{
if (isDark)
{
LabelBackgroundColor = Color.FromArgb("#1C1C1E");
TitleColor = Colors.White;
DescriptionColor = Color.FromArgb("#ABABAB");
Stroke = Color.FromArgb("#3A3A3C");
}
else
{
LabelBackgroundColor = Colors.White;
TitleColor = Colors.Black;
DescriptionColor = Colors.DarkGray;
Stroke = Colors.Transparent;
}
}
/// <summary>
/// Applies RTL or LTR flow direction to the label layout.
/// </summary>
/// <param name="isRtl">True for right-to-left layout.</param>
public void ApplyFlowDirection(bool isRtl)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => ApplyFlowDirection(isRtl));
return;
}
// Update text alignment for labels
_titleLabel.HorizontalTextAlignment = isRtl ? TextAlignment.End : TextAlignment.Start;
_descriptionLabel.HorizontalTextAlignment = isRtl ? TextAlignment.End : TextAlignment.Start;
}
#region Animations
/// <summary>
/// Shows the inline label with an entrance animation.
/// </summary>
/// <param name="config">Animation configuration to use.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ShowWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
{
config ??= AnimationConfiguration.Default;
await AnimationHelper.PlayEntranceAsync(this, config.InlineLabelEntrance, config, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Hides the inline label with an exit animation.
/// </summary>
/// <param name="config">Animation configuration to use.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task HideWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
{
config ??= AnimationConfiguration.Default;
await AnimationHelper.PlayExitAsync(this, config.InlineLabelExit, config, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Resets all animation properties to default.
/// </summary>
public void ResetAnimationState()
{
AnimationHelper.ResetElement(this);
}
#endregion
}