420 lines
14 KiB
C#
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
|
|
}
|