352 lines
12 KiB
C#
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
|
|
}
|