Files

420 lines
14 KiB
C#

using Microsoft.Maui.Controls.Shapes;
using MarketAlly.SpotlightTour.Maui.Animations;
using Path = Microsoft.Maui.Controls.Shapes.Path;
namespace MarketAlly.SpotlightTour.Maui;
/// <summary>
/// A visual overlay that dims the screen with a spotlight cutout.
/// </summary>
public class SpotlightOverlay : Grid
{
private readonly Path _dimPath;
private readonly TourConfiguration _config;
private Rect _spotlightRect;
private SpotlightShape _spotlightShape = SpotlightShape.RoundedRectangle;
private double _cornerRadius;
private CancellationTokenSource? _effectCts;
private bool _isEffectRunning;
/// <summary>
/// The opacity of the dimmed area (0-1).
/// </summary>
public static readonly BindableProperty DimOpacityProperty =
BindableProperty.Create(
nameof(DimOpacity),
typeof(double),
typeof(SpotlightOverlay),
0.6,
propertyChanged: (b, _, _) => ((SpotlightOverlay)b).UpdatePath());
public double DimOpacity
{
get => (double)GetValue(DimOpacityProperty);
set => SetValue(DimOpacityProperty, value);
}
/// <summary>
/// The color of the dimmed overlay.
/// </summary>
public static readonly BindableProperty DimColorProperty =
BindableProperty.Create(
nameof(DimColor),
typeof(Color),
typeof(SpotlightOverlay),
Colors.Black,
propertyChanged: (b, _, _) => ((SpotlightOverlay)b).UpdatePath());
public Color DimColor
{
get => (Color)GetValue(DimColorProperty);
set => SetValue(DimColorProperty, value);
}
/// <summary>
/// Event raised when the spotlight area is tapped.
/// </summary>
public event EventHandler? SpotlightTapped;
/// <summary>
/// Event raised when the dimmed area (outside spotlight) is tapped.
/// </summary>
public event EventHandler? DimmedAreaTapped;
public SpotlightOverlay() : this(TourConfiguration.Default) { }
public SpotlightOverlay(TourConfiguration config)
{
ArgumentNullException.ThrowIfNull(config);
_config = config;
_cornerRadius = config.DefaultSpotlightCornerRadius;
BackgroundColor = Colors.Transparent;
InputTransparent = false;
HorizontalOptions = LayoutOptions.Fill;
VerticalOptions = LayoutOptions.Fill;
_dimPath = new Path
{
Fill = new SolidColorBrush(DimColor.WithAlpha((float)DimOpacity)),
InputTransparent = true,
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill
};
Children.Add(_dimPath);
SizeChanged += (_, _) => UpdatePath();
// Handle taps
var tapGesture = new TapGestureRecognizer();
tapGesture.Tapped += OnOverlayTapped;
GestureRecognizers.Add(tapGesture);
}
private void OnOverlayTapped(object? sender, TappedEventArgs e)
{
var position = e.GetPosition(this);
if (position.HasValue && _spotlightRect.Contains(position.Value))
{
SpotlightTapped?.Invoke(this, EventArgs.Empty);
}
else
{
DimmedAreaTapped?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// Sets the spotlight cutout rectangle with shape options.
/// </summary>
public void SetSpotlight(Rect rect, SpotlightShape shape = SpotlightShape.RoundedRectangle, double cornerRadius = 8.0)
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(() => SetSpotlight(rect, shape, cornerRadius));
return;
}
_spotlightRect = rect;
_spotlightShape = shape;
_cornerRadius = cornerRadius;
UpdatePath();
}
/// <summary>
/// Clears the spotlight (full dim).
/// </summary>
public void ClearSpotlight()
{
// Ensure UI updates happen on the main thread
if (!MainThread.IsMainThread)
{
MainThread.BeginInvokeOnMainThread(ClearSpotlight);
return;
}
_spotlightRect = Rect.Zero;
UpdatePath();
}
/// <summary>
/// Gets the current spotlight rectangle.
/// </summary>
public Rect SpotlightRect => _spotlightRect;
private void UpdatePath()
{
if (Width <= 0 || Height <= 0)
return;
_dimPath.Fill = new SolidColorBrush(DimColor.WithAlpha((float)DimOpacity));
var geometry = new PathGeometry
{
FillRule = FillRule.EvenOdd
};
// Outer full-screen rectangle
var outerFigure = new PathFigure
{
StartPoint = new Point(0, 0),
IsClosed = true
};
outerFigure.Segments.Add(new LineSegment(new Point(Width, 0)));
outerFigure.Segments.Add(new LineSegment(new Point(Width, Height)));
outerFigure.Segments.Add(new LineSegment(new Point(0, Height)));
outerFigure.Segments.Add(new LineSegment(new Point(0, 0)));
geometry.Figures.Add(outerFigure);
// Inner spotlight hole (if valid)
if (_spotlightRect.Width > 0 && _spotlightRect.Height > 0)
{
var innerFigure = CreateSpotlightFigure();
geometry.Figures.Add(innerFigure);
}
_dimPath.Data = geometry;
}
private PathFigure CreateSpotlightFigure()
{
return _spotlightShape switch
{
SpotlightShape.Rectangle => CreateRectangleFigure(),
SpotlightShape.RoundedRectangle => CreateRoundedRectangleFigure(),
SpotlightShape.Circle => CreateCircleFigure(),
_ => CreateRoundedRectangleFigure()
};
}
private PathFigure CreateRectangleFigure()
{
var figure = new PathFigure
{
StartPoint = new Point(_spotlightRect.Left, _spotlightRect.Top),
IsClosed = true
};
figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Right, _spotlightRect.Top)));
figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Right, _spotlightRect.Bottom)));
figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Left, _spotlightRect.Bottom)));
figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Left, _spotlightRect.Top)));
return figure;
}
private PathFigure CreateRoundedRectangleFigure()
{
var r = Math.Min(_cornerRadius, Math.Min(_spotlightRect.Width / 2, _spotlightRect.Height / 2));
var rect = _spotlightRect;
var figure = new PathFigure
{
StartPoint = new Point(rect.Left + r, rect.Top),
IsClosed = true
};
// Top edge
figure.Segments.Add(new LineSegment(new Point(rect.Right - r, rect.Top)));
// Top-right corner
figure.Segments.Add(new ArcSegment
{
Point = new Point(rect.Right, rect.Top + r),
Size = new Size(r, r),
SweepDirection = SweepDirection.Clockwise
});
// Right edge
figure.Segments.Add(new LineSegment(new Point(rect.Right, rect.Bottom - r)));
// Bottom-right corner
figure.Segments.Add(new ArcSegment
{
Point = new Point(rect.Right - r, rect.Bottom),
Size = new Size(r, r),
SweepDirection = SweepDirection.Clockwise
});
// Bottom edge
figure.Segments.Add(new LineSegment(new Point(rect.Left + r, rect.Bottom)));
// Bottom-left corner
figure.Segments.Add(new ArcSegment
{
Point = new Point(rect.Left, rect.Bottom - r),
Size = new Size(r, r),
SweepDirection = SweepDirection.Clockwise
});
// Left edge
figure.Segments.Add(new LineSegment(new Point(rect.Left, rect.Top + r)));
// Top-left corner
figure.Segments.Add(new ArcSegment
{
Point = new Point(rect.Left + r, rect.Top),
Size = new Size(r, r),
SweepDirection = SweepDirection.Clockwise
});
return figure;
}
private PathFigure CreateCircleFigure()
{
var center = new Point(_spotlightRect.Center.X, _spotlightRect.Center.Y);
// Use the diagonal to create a circle that encompasses the rectangle
var radius = Math.Sqrt(_spotlightRect.Width * _spotlightRect.Width + _spotlightRect.Height * _spotlightRect.Height) / 2;
// But cap it to not be excessively large (use configured scale)
radius = Math.Min(radius, Math.Max(_spotlightRect.Width, _spotlightRect.Height) * _config.CircleSpotlightScale);
var figure = new PathFigure
{
StartPoint = new Point(center.X - radius, center.Y),
IsClosed = true
};
// Upper half
figure.Segments.Add(new ArcSegment
{
Point = new Point(center.X + radius, center.Y),
Size = new Size(radius, radius),
SweepDirection = SweepDirection.Clockwise,
IsLargeArc = true
});
// Lower half
figure.Segments.Add(new ArcSegment
{
Point = new Point(center.X - radius, center.Y),
Size = new Size(radius, radius),
SweepDirection = SweepDirection.Clockwise,
IsLargeArc = true
});
return figure;
}
/// <summary>
/// Animates the spotlight to a new position.
/// </summary>
public async Task AnimateToAsync(Rect targetRect, SpotlightShape shape, double cornerRadius, uint duration = 250)
{
var startRect = _spotlightRect;
_spotlightShape = shape;
_cornerRadius = cornerRadius;
var animation = new Animation(v =>
{
_spotlightRect = new Rect(
startRect.X + (targetRect.X - startRect.X) * v,
startRect.Y + (targetRect.Y - startRect.Y) * v,
startRect.Width + (targetRect.Width - startRect.Width) * v,
startRect.Height + (targetRect.Height - startRect.Height) * v);
UpdatePath();
});
var tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "SpotlightAnimation", length: duration, easing: Easing.CubicInOut,
finished: (_, _) => tcs.SetResult(true));
await tcs.Task;
}
#region Animation Effects
/// <summary>
/// Shows the overlay with a fade-in 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.OverlayEntrance, config, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Hides the overlay with a fade-out 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;
StopSpotlightEffect();
await AnimationHelper.PlayExitAsync(this, config.OverlayExit, config, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Starts a spotlight emphasis effect (pulse, breathe, glow, etc.).
/// </summary>
/// <param name="effect">The effect to play.</param>
/// <param name="config">Animation configuration to use.</param>
public void StartSpotlightEffect(SpotlightEffect effect, AnimationConfiguration? config = null)
{
if (_isEffectRunning || effect == SpotlightEffect.None)
return;
config ??= AnimationConfiguration.Default;
_effectCts = new CancellationTokenSource();
_isEffectRunning = true;
// Fire and forget - effect runs in background
_ = RunSpotlightEffectAsync(effect, config, _effectCts.Token);
}
/// <summary>
/// Stops any running spotlight effect.
/// </summary>
public void StopSpotlightEffect()
{
if (!_isEffectRunning)
return;
_effectCts?.Cancel();
_effectCts?.Dispose();
_effectCts = null;
_isEffectRunning = false;
// Reset scale
this.CancelAnimations();
Scale = 1;
}
private async Task RunSpotlightEffectAsync(SpotlightEffect effect, AnimationConfiguration config, CancellationToken cancellationToken)
{
try
{
await AnimationHelper.PlaySpotlightEffectAsync(this, effect, config, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected when stopping effect
}
finally
{
_isEffectRunning = false;
}
}
/// <summary>
/// Plays a single pulse animation on the spotlight.
/// </summary>
public async Task PulseOnceAsync(uint duration = 600)
{
var animation = new Animation();
animation.Add(0, 0.5, new Animation(v => Scale = v, 1, 1.02, Easing.SinOut));
animation.Add(0.5, 1.0, new Animation(v => Scale = v, 1.02, 1, Easing.SinIn));
var tcs = new TaskCompletionSource<bool>();
animation.Commit(this, "PulseOnce", length: duration, finished: (_, _) => tcs.SetResult(true));
await tcs.Task.ConfigureAwait(false);
}
/// <summary>
/// Resets all animation properties to default.
/// </summary>
public void ResetAnimationState()
{
StopSpotlightEffect();
AnimationHelper.ResetElement(this);
}
#endregion
}