Add step actions

This commit is contained in:
2025-12-16 23:22:01 -05:00
parent c431f43a30
commit 1bb448b1a6
11 changed files with 1025 additions and 6 deletions

View File

@@ -203,10 +203,48 @@ Dismisses the intro view and continues with the tour.
| `TourCompleted` | `EventArgs` | Raised when the user completes all steps. |
| `TourSkipped` | `EventArgs` | Raised when the user skips the tour. |
| `TourEnded` | `EventArgs` | Raised when the tour ends (any reason). |
| `StepEntering` | `StepActionEventArgs` | Raised before entering a step. Async. Can set `Skip = true`. |
| `StepEntered` | `StepActionEventArgs` | Raised after step is fully visible (after animations). |
| `StepLeaving` | `StepActionEventArgs` | Raised before leaving the current step. Async. |
| `StepChanged` | `OnboardingStepEventArgs` | Raised when moving to a new step. |
| `IntroShown` | `EventArgs` | Raised when the intro view is shown. |
| `IntroDismissed` | `EventArgs` | Raised when the intro view is dismissed. |
### Step Action Registration
```csharp
void RegisterStepEnteringAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
```
Registers an action to execute when entering a specific step (by StepKey). The action runs before the spotlight animates to the step.
---
```csharp
void RegisterStepLeavingAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
```
Registers an action to execute when leaving a specific step (by StepKey). The action runs before navigating away.
---
```csharp
bool UnregisterStepEnteringAction(string stepKey)
```
Removes a registered entering action for a step. Returns true if the action was removed.
---
```csharp
bool UnregisterStepLeavingAction(string stepKey)
```
Removes a registered leaving action for a step. Returns true if the action was removed.
---
```csharp
void ClearStepActions()
```
Clears all registered step actions (both entering and leaving).
### Usage Example
```xml
@@ -268,6 +306,13 @@ Static class providing attached properties for tagging UI elements as onboarding
| `Placement` | `CalloutPlacement` | `Auto` | Where to place the callout relative to this element. |
| `TapBehavior` | `SpotlightTapBehavior` | `None` | Behavior when user taps the spotlight. |
#### Step Actions
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `OnEntering` | `Func<OnboardingStep, CancellationToken, Task>?` | `null` | Action to execute when entering this step. Runs before spotlight animation. |
| `OnLeaving` | `Func<OnboardingStep, CancellationToken, Task>?` | `null` | Action to execute when leaving this step. Runs before navigating away. |
### Static Methods
```csharp
@@ -312,6 +357,19 @@ Onboarding.SetSpotlightShape(myButton, SpotlightShape.Circle);
// Read properties
var key = Onboarding.GetStepKey(myButton);
var title = Onboarding.GetTitle(myButton);
// Set step actions (async actions that run when entering/leaving this step)
Onboarding.SetOnEntering(myButton, async (step, cancellationToken) =>
{
// Prepare UI before spotlight appears (e.g., switch tabs, open drawer)
MainTabView.SelectedIndex = 2;
await Task.Delay(100);
});
Onboarding.SetOnLeaving(myButton, async (step, cancellationToken) =>
{
// Cleanup when leaving this step
});
```
---
@@ -336,6 +394,8 @@ Represents a single step in an onboarding tour. Created automatically from tagge
| `SpotlightPadding` | `Thickness` | Padding around target. Default: `8`. |
| `SpotlightCornerRadius` | `double` | Corner radius for rounded rectangle. Default: `8.0`. |
| `TapBehavior` | `SpotlightTapBehavior` | Behavior when tapping spotlight. Default: `None`. |
| `OnEntering` | `Func<OnboardingStep, CancellationToken, Task>?` | Action to execute when entering this step. |
| `OnLeaving` | `Func<OnboardingStep, CancellationToken, Task>?` | Action to execute when leaving this step. |
### Static Methods
@@ -559,6 +619,64 @@ TourHost.StepChanged += (sender, e) =>
---
### StepActionEventArgs
Event arguments for step action events (`StepEntering`, `StepEntered`, `StepLeaving`).
| Property | Type | Description |
|----------|------|-------------|
| `Step` | `OnboardingStep` | The step being entered or left. |
| `StepIndex` | `int` | Zero-based index of the step. |
| `TotalSteps` | `int` | Total number of steps in the tour. |
| `PreviousStepIndex` | `int` | Index of the previous step (-1 if first step). |
| `IsForward` | `bool` | Whether navigation is moving forward (true) or backward (false). |
| `Skip` | `bool` | Set to `true` to skip this step (only applies to `StepEntering`). |
| `CancellationToken` | `CancellationToken` | Cancellation token for the current operation. |
### Step Action Usage Example
```csharp
// Register action by StepKey
TourHost.RegisterStepEnteringAction("settings-tab", async (step, token) =>
{
// Switch to settings tab before spotlight appears
SettingsTabView.SelectedIndex = 1;
await Task.Delay(150); // Wait for tab transition
});
// Use event for conditional skipping
TourHost.StepEntering += async (sender, e) =>
{
// Skip premium-only steps for free users
if (e.Step.StepKey?.StartsWith("premium-") == true && !User.IsPremium)
{
e.Skip = true;
}
};
// Track when steps are viewed
TourHost.StepEntered += (sender, e) =>
{
Analytics.Track("tour_step_viewed", new {
step_key = e.Step.StepKey,
step_index = e.StepIndex,
is_forward = e.IsForward
});
};
// Cleanup when leaving steps
TourHost.StepLeaving += async (sender, e) =>
{
if (e.Step.StepKey == "drawer-step")
{
Shell.Current.FlyoutIsPresented = false;
await Task.Delay(200);
}
};
```
---
## Complete Integration Example
### XAML (MainPage.xaml)

View File

@@ -17,7 +17,7 @@
<PackageId>MarketAlly.SpotlightTour.Maui</PackageId>
<Title>MASpotlightTour - Feature Tour &amp; Onboarding for .NET MAUI</Title>
<Version>1.0.0</Version>
<Version>1.1.0</Version>
<Authors>David H Friedel Jr</Authors>
<Company>MarketAlly</Company>
<Description>A powerful, declarative spotlight tour and onboarding library for .NET MAUI applications. Create beautiful feature tours with spotlight overlays, callout cards, and step-by-step guidance using simple XAML attached properties. Features include: multiple display modes (spotlight with callout, callout-only, inline labels), flexible callout positioning (following, fixed corner, auto-corner), customizable spotlight shapes (rectangle, rounded rectangle, circle), corner navigator with step indicators, intro/welcome views, auto-advance timers, tour looping, light/dark theme support, smooth animations, and awaitable tour completion for action chaining. Perfect for user onboarding, feature discovery, and interactive tutorials. Supports iOS, Android, Windows, and macOS.</Description>
@@ -30,6 +30,15 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>
Version 1.1.0 - Step Actions:
- NEW: Step Actions - Execute custom async code when entering/leaving tour steps
- NEW: OnEntering/OnLeaving attached properties for per-element actions
- NEW: RegisterStepEnteringAction/RegisterStepLeavingAction for centralized registration by StepKey
- NEW: StepEntering event with Skip property for conditional step skipping
- NEW: StepEntered event fired after step animations complete
- NEW: StepLeaving event for cleanup before navigation
- Perfect for UI preparation: switching tabs, opening drawers, triggering animations
Version 1.0.0 - Initial Release:
- Declarative tour steps via XAML attached properties
- Three display modes: SpotlightWithCallout, CalloutOnly, SpotlightWithInlineLabel

View File

@@ -225,6 +225,49 @@ public static class Onboarding
#endregion
#region OnEntering
/// <summary>
/// Action to execute when the tour enters this step.
/// The action runs before the spotlight animates to the element, allowing UI preparation
/// (e.g., switching tabs, opening drawers) to complete first.
/// </summary>
public static readonly BindableProperty OnEnteringProperty =
BindableProperty.CreateAttached(
"OnEntering",
typeof(Func<OnboardingStep, CancellationToken, Task>),
typeof(Onboarding),
defaultValue: null);
public static Func<OnboardingStep, CancellationToken, Task>? GetOnEntering(BindableObject view) =>
(Func<OnboardingStep, CancellationToken, Task>?)view.GetValue(OnEnteringProperty);
public static void SetOnEntering(BindableObject view, Func<OnboardingStep, CancellationToken, Task>? value) =>
view.SetValue(OnEnteringProperty, value);
#endregion
#region OnLeaving
/// <summary>
/// Action to execute when the tour leaves this step.
/// The action runs before navigating to the next/previous step.
/// </summary>
public static readonly BindableProperty OnLeavingProperty =
BindableProperty.CreateAttached(
"OnLeaving",
typeof(Func<OnboardingStep, CancellationToken, Task>),
typeof(Onboarding),
defaultValue: null);
public static Func<OnboardingStep, CancellationToken, Task>? GetOnLeaving(BindableObject view) =>
(Func<OnboardingStep, CancellationToken, Task>?)view.GetValue(OnLeavingProperty);
public static void SetOnLeaving(BindableObject view, Func<OnboardingStep, CancellationToken, Task>? value) =>
view.SetValue(OnLeavingProperty, value);
#endregion
/// <summary>
/// Helper to get the effective step key, falling back to AutomationId.
/// </summary>

View File

@@ -27,6 +27,8 @@ public class OnboardingHost : Grid, IDisposable
private CalloutCorner? _currentCalloutCorner;
private readonly SemaphoreSlim _tourLock = new(1, 1);
private bool _isDisposed;
private readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _enteringActions = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Func<OnboardingStep, CancellationToken, Task>> _leavingActions = new(StringComparer.OrdinalIgnoreCase);
#region Bindable Properties
@@ -351,6 +353,24 @@ public class OnboardingHost : Grid, IDisposable
/// </summary>
public event EventHandler? IntroDismissed;
/// <summary>
/// Raised before entering a step, allowing async preparation or skipping.
/// Actions execute before the spotlight animates to the new position.
/// Set e.Skip = true to skip this step.
/// </summary>
public event Func<object?, StepActionEventArgs, Task>? StepEntering;
/// <summary>
/// Raised after a step is fully visible (after animations complete).
/// </summary>
public event EventHandler<StepActionEventArgs>? StepEntered;
/// <summary>
/// Raised before leaving the current step.
/// Actions execute before navigating to the next/previous step.
/// </summary>
public event Func<object?, StepActionEventArgs, Task>? StepLeaving;
#endregion
#region Read-only Properties
@@ -384,6 +404,65 @@ public class OnboardingHost : Grid, IDisposable
#endregion
#region Step Action Registration
/// <summary>
/// Registers an action to execute when entering a specific step (by StepKey).
/// The action runs before the spotlight animates to the step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <param name="action">The async action to execute. Receives the step and cancellation token.</param>
public void RegisterStepEnteringAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(stepKey);
ArgumentNullException.ThrowIfNull(action);
_enteringActions[stepKey] = action;
}
/// <summary>
/// Registers an action to execute when leaving a specific step (by StepKey).
/// The action runs before navigating away from the step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <param name="action">The async action to execute. Receives the step and cancellation token.</param>
public void RegisterStepLeavingAction(string stepKey, Func<OnboardingStep, CancellationToken, Task> action)
{
ArgumentNullException.ThrowIfNull(stepKey);
ArgumentNullException.ThrowIfNull(action);
_leavingActions[stepKey] = action;
}
/// <summary>
/// Removes a registered entering action for a step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <returns>True if the action was removed, false if no action was registered.</returns>
public bool UnregisterStepEnteringAction(string stepKey)
{
return _enteringActions.Remove(stepKey);
}
/// <summary>
/// Removes a registered leaving action for a step.
/// </summary>
/// <param name="stepKey">The StepKey of the target step.</param>
/// <returns>True if the action was removed, false if no action was registered.</returns>
public bool UnregisterStepLeavingAction(string stepKey)
{
return _leavingActions.Remove(stepKey);
}
/// <summary>
/// Clears all registered step actions.
/// </summary>
public void ClearStepActions()
{
_enteringActions.Clear();
_leavingActions.Clear();
}
#endregion
public OnboardingHost()
{
IsVisible = false;
@@ -798,6 +877,131 @@ public class OnboardingHost : Grid, IDisposable
return await _tourCompletionSource.Task;
}
/// <summary>
/// Executes all entering actions for a step (event, attached property, registered action).
/// Returns a StepActionEventArgs that may have Skip set to true.
/// </summary>
private async Task<StepActionEventArgs> ExecuteStepEnteringActionsAsync(
OnboardingStep step,
int stepIndex,
int previousIndex,
CancellationToken cancellationToken = default)
{
var isForward = previousIndex < stepIndex || previousIndex < 0;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, previousIndex, isForward, cancellationToken);
// 1. Fire StepEntering event (allows Skip)
if (StepEntering != null)
{
try
{
await StepEntering.Invoke(this, eventArgs).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] StepEntering event handler failed: {ex.Message}");
}
}
// If skipped via event, don't run other actions
if (eventArgs.Skip)
return eventArgs;
// 2. Execute attached property action (OnEntering on the element)
if (step.OnEntering != null)
{
try
{
await step.OnEntering(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnEntering attached action failed: {ex.Message}");
}
}
// 3. Execute registered action (by StepKey)
if (!string.IsNullOrEmpty(step.StepKey) && _enteringActions.TryGetValue(step.StepKey, out var registeredAction))
{
try
{
await registeredAction(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Registered entering action failed: {ex.Message}");
}
}
return eventArgs;
}
/// <summary>
/// Executes all leaving actions for a step (event, attached property, registered action).
/// </summary>
private async Task ExecuteStepLeavingActionsAsync(
OnboardingStep step,
int stepIndex,
int nextIndex,
CancellationToken cancellationToken = default)
{
var isForward = nextIndex > stepIndex;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, stepIndex, isForward, cancellationToken);
// 1. Fire StepLeaving event
if (StepLeaving != null)
{
try
{
await StepLeaving.Invoke(this, eventArgs).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] StepLeaving event handler failed: {ex.Message}");
}
}
// 2. Execute attached property action (OnLeaving on the element)
if (step.OnLeaving != null)
{
try
{
await step.OnLeaving(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] OnLeaving attached action failed: {ex.Message}");
}
}
// 3. Execute registered action (by StepKey)
if (!string.IsNullOrEmpty(step.StepKey) && _leavingActions.TryGetValue(step.StepKey, out var registeredAction))
{
try
{
await registeredAction(step, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Registered leaving action failed: {ex.Message}");
}
}
}
/// <summary>
/// Fires the StepEntered event after animations complete.
/// </summary>
private void RaiseStepEntered(OnboardingStep step, int stepIndex, int previousIndex)
{
var isForward = previousIndex < stepIndex || previousIndex < 0;
var eventArgs = new StepActionEventArgs(
step, stepIndex, _steps.Count, previousIndex, isForward);
StepEntered?.Invoke(this, eventArgs);
}
/// <summary>
/// Navigates to a specific step by index.
/// </summary>
@@ -812,9 +1016,40 @@ public class OnboardingHost : Grid, IDisposable
}
var previousIndex = _currentIndex;
var step = _steps[index];
// Execute leaving actions for current step (if any)
if (previousIndex >= 0 && previousIndex < _steps.Count)
{
var previousStep = _steps[previousIndex];
await ExecuteStepLeavingActionsAsync(previousStep, previousIndex, index).ConfigureAwait(false);
}
// Execute entering actions for new step (before UI updates)
var enteringArgs = await ExecuteStepEnteringActionsAsync(step, index, previousIndex).ConfigureAwait(false);
// Handle skip - move to next or previous step
if (enteringArgs.Skip)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Step {index} skipped via StepEntering event");
var isForward = previousIndex < index || previousIndex < 0;
var nextIndex = isForward ? index + 1 : index - 1;
if (nextIndex >= 0 && nextIndex < _steps.Count)
{
await GoToStepAsync(nextIndex).ConfigureAwait(false);
}
else if (isForward)
{
// No more steps forward, complete tour
await CompleteTourAsync().ConfigureAwait(false);
}
// If going backward with no more steps, stay at current
return;
}
_currentIndex = index;
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] GoToStepAsync: previousIndex={previousIndex}, new _currentIndex={_currentIndex}");
var step = _steps[index];
// Ensure element is visible (scroll if needed) - with timeout to prevent hanging
try
@@ -899,7 +1134,10 @@ public class OnboardingHost : Grid, IDisposable
}
_cornerNavigator.UpdateAutoPlacement(bounds, new Size(Width, Height), calloutBounds, _currentCalloutCorner);
// Raise event
// Raise StepEntered event (after animations complete)
RaiseStepEntered(step, index, previousIndex);
// Raise legacy StepChanged event
StepChanged?.Invoke(this, new OnboardingStepEventArgs(step, index, _steps.Count));
// Start auto-advance timer if enabled
@@ -1358,9 +1596,39 @@ public class OnboardingHost : Grid, IDisposable
var config = TourConfiguration.Default;
var previousIndex = _currentIndex;
_currentIndex = index;
var step = _steps[index];
// Execute leaving actions for current step (if any)
if (previousIndex >= 0 && previousIndex < _steps.Count)
{
var previousStep = _steps[previousIndex];
await ExecuteStepLeavingActionsAsync(previousStep, previousIndex, index, cancellationToken).ConfigureAwait(false);
}
// Execute entering actions for new step (before UI updates)
var enteringArgs = await ExecuteStepEnteringActionsAsync(step, index, previousIndex, cancellationToken).ConfigureAwait(false);
// Handle skip - move to next or previous step
if (enteringArgs.Skip)
{
System.Diagnostics.Debug.WriteLine($"[OnboardingHost] Step {index} skipped via StepEntering event (internal)");
var isForward = previousIndex < index || previousIndex < 0;
var nextIndex = isForward ? index + 1 : index - 1;
if (nextIndex >= 0 && nextIndex < _steps.Count)
{
await GoToStepInternalAsync(nextIndex, cancellationToken).ConfigureAwait(false);
}
else if (isForward)
{
// No more steps forward, complete tour
await CompleteTourAsync().ConfigureAwait(false);
}
return;
}
_currentIndex = index;
// Ensure element is visible (scroll if needed) - with timeout to prevent hanging
try
{
@@ -1448,7 +1716,10 @@ public class OnboardingHost : Grid, IDisposable
}
_cornerNavigator.UpdateAutoPlacement(bounds, new Size(Width, Height), calloutBounds, _currentCalloutCorner);
// Raise event
// Raise StepEntered event (after animations complete)
RaiseStepEntered(step, index, previousIndex);
// Raise legacy StepChanged event
StepChanged?.Invoke(this, new OnboardingStepEventArgs(step, index, _steps.Count));
// Start auto-advance timer if enabled
@@ -1843,3 +2114,62 @@ public class OnboardingStepEventArgs : EventArgs
TotalSteps = totalSteps;
}
}
/// <summary>
/// Event args for step action events (StepEntering, StepLeaving).
/// Allows actions to skip steps or perform async preparation.
/// </summary>
public class StepActionEventArgs : EventArgs
{
/// <summary>
/// The step being entered or left.
/// </summary>
public OnboardingStep Step { get; }
/// <summary>
/// The index of the step (0-based).
/// </summary>
public int StepIndex { get; }
/// <summary>
/// Total number of steps in the tour.
/// </summary>
public int TotalSteps { get; }
/// <summary>
/// The index of the previous step (-1 if this is the first step).
/// </summary>
public int PreviousStepIndex { get; }
/// <summary>
/// Whether navigation is moving forward (true) or backward (false).
/// </summary>
public bool IsForward { get; }
/// <summary>
/// Set to true to skip this step and move to the next one.
/// Only applies to StepEntering event.
/// </summary>
public bool Skip { get; set; }
/// <summary>
/// Cancellation token for the current operation.
/// </summary>
public CancellationToken CancellationToken { get; }
public StepActionEventArgs(
OnboardingStep step,
int stepIndex,
int totalSteps,
int previousStepIndex,
bool isForward,
CancellationToken cancellationToken = default)
{
Step = step;
StepIndex = stepIndex;
TotalSteps = totalSteps;
PreviousStepIndex = previousStepIndex;
IsForward = isForward;
CancellationToken = cancellationToken;
}
}

View File

@@ -65,6 +65,18 @@ public class OnboardingStep
/// </summary>
public SpotlightTapBehavior TapBehavior { get; init; } = SpotlightTapBehavior.None;
/// <summary>
/// Action to execute when entering this step.
/// Runs before the spotlight animates to allow UI preparation.
/// </summary>
public Func<OnboardingStep, CancellationToken, Task>? OnEntering { get; init; }
/// <summary>
/// Action to execute when leaving this step.
/// Runs before navigating to the next/previous step.
/// </summary>
public Func<OnboardingStep, CancellationToken, Task>? OnLeaving { get; init; }
/// <summary>
/// Creates an OnboardingStep from a tagged VisualElement.
/// </summary>
@@ -83,7 +95,9 @@ public class OnboardingStep
SpotlightShape = Onboarding.GetSpotlightShape(element),
SpotlightPadding = Onboarding.GetSpotlightPadding(element),
SpotlightCornerRadius = Onboarding.GetSpotlightCornerRadius(element),
TapBehavior = Onboarding.GetTapBehavior(element)
TapBehavior = Onboarding.GetTapBehavior(element),
OnEntering = Onboarding.GetOnEntering(element),
OnLeaving = Onboarding.GetOnLeaving(element)
};
}
}

View File

@@ -332,17 +332,109 @@ TourHost.SkipTour();
TourHost.StopTour();
```
## Step Actions
Execute custom code when entering or leaving tour steps. Perfect for UI preparation like switching tabs, opening drawers, or triggering animations before the spotlight appears.
### Attached Property (Per-Element)
```csharp
// In code-behind, attach action directly to the element
Onboarding.SetOnEntering(SettingsTab, async (step, cancellationToken) =>
{
// Switch to the settings tab before spotlight appears
MainTabView.SelectedIndex = 2;
await Task.Delay(100); // Wait for tab transition
});
Onboarding.SetOnLeaving(SettingsTab, async (step, cancellationToken) =>
{
// Cleanup when leaving this step
MainTabView.SelectedIndex = 0;
});
```
### Centralized Registration (By StepKey)
```csharp
// Register actions by step key - keeps tour logic organized
TourHost.RegisterStepEnteringAction("drawer-step", async (step, token) =>
{
Shell.Current.FlyoutIsPresented = true;
await Task.Delay(250); // Wait for drawer animation
});
TourHost.RegisterStepEnteringAction("settings-tab", async (step, token) =>
{
SettingsTabButton.IsSelected = true;
});
// Remove registered actions when no longer needed
TourHost.UnregisterStepEnteringAction("drawer-step");
TourHost.ClearStepActions(); // Clear all
```
### Step Action Events
```csharp
// Global event - fires for every step (useful for analytics, logging, conditional skipping)
TourHost.StepEntering += async (sender, e) =>
{
// Skip premium features for non-premium users
if (e.Step.StepKey == "premium-feature" && !currentUser.IsPremium)
{
e.Skip = true; // Skip this step, move to next
return;
}
// Log analytics
Analytics.Track("tour_step_entering", e.Step.StepKey);
};
// Fires after step is fully visible (animations complete)
TourHost.StepEntered += (sender, e) =>
{
Console.WriteLine($"Now viewing step: {e.Step.Title}");
};
// Fires before leaving current step
TourHost.StepLeaving += async (sender, e) =>
{
// Perform cleanup before navigating away
await SaveStepProgressAsync(e.StepIndex);
};
```
### Execution Order
Actions execute in this order when navigating to a step:
1. `StepLeaving` event + actions (previous step)
2. `StepEntering` event (can set `Skip = true`)
3. `OnEntering` attached property action
4. Registered entering action (by StepKey)
5. Scroll target into view & animate spotlight
6. `StepEntered` event
## Events
```csharp
// Tour lifecycle events
TourHost.TourStarted += (s, e) => { /* Tour began */ };
TourHost.TourCompleted += (s, e) => { /* User finished all steps */ };
TourHost.TourSkipped += (s, e) => { /* User skipped */ };
TourHost.TourEnded += (s, e) => { /* Tour ended (any reason) */ };
// Step navigation events
TourHost.StepEntering += async (s, e) => { /* Before entering step - can set e.Skip = true */ };
TourHost.StepEntered += (s, e) => { /* After step is visible */ };
TourHost.StepLeaving += async (s, e) => { /* Before leaving step */ };
TourHost.StepChanged += (s, e) =>
{
Console.WriteLine($"Step {e.StepIndex + 1}/{e.TotalSteps}: {e.Step.Title}");
};
// Intro events
TourHost.IntroShown += (s, e) => { /* Intro view displayed */ };
TourHost.IntroDismissed += (s, e) => { /* Intro view dismissed */ };
```

View File

@@ -77,4 +77,11 @@
Route="AnimationsPage" />
</FlyoutItem>
<FlyoutItem Title="Step Actions" Icon="{OnPlatform Default=icon_action.png}">
<ShellContent
Title="Step Actions"
ContentTemplate="{DataTemplate pages:StepActionsPage}"
Route="StepActionsPage" />
</FlyoutItem>
</Shell>

View File

@@ -150,6 +150,16 @@
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#009688" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnStepActionsTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Step Actions" FontAttributes="Bold" TextColor="White" />
<Label Text="Execute code when entering/leaving steps (tab switching, drawers)" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<BoxView HeightRequest="10" Color="Transparent" />
<Label

View File

@@ -91,4 +91,7 @@ public partial class MainPage : ContentPage
private async void OnAnimationsTapped(object? sender, EventArgs e)
=> await Shell.Current.GoToAsync("//AnimationsPage");
private async void OnStepActionsTapped(object? sender, EventArgs e)
=> await Shell.Current.GoToAsync("//StepActionsPage");
}

View File

@@ -0,0 +1,245 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.SpotlightTour.Maui;assembly=MarketAlly.SpotlightTour.Maui"
x:Class="Test.SpotlightTour.Pages.StepActionsPage"
Title="Step Actions">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Header -->
<Border Grid.Row="0" Padding="20" StrokeThickness="0"
BackgroundColor="{AppThemeBinding Light={StaticResource Primary}, Dark=#1E3A5F}">
<VerticalStackLayout Spacing="8">
<Label
Text="Step Actions Demo"
FontSize="24"
FontAttributes="Bold"
TextColor="White"
onboard:Onboarding.StepKey="header"
onboard:Onboarding.Title="Step Actions"
onboard:Onboarding.Description="This demo shows how to execute custom code when entering or leaving tour steps."
onboard:Onboarding.Order="1" />
<Label
Text="Watch tabs switch automatically as the tour progresses!"
TextColor="{AppThemeBinding Light=#FFFFFFCC, Dark=#FFFFFFAA}" />
</VerticalStackLayout>
</Border>
<!-- Main Content with Tabs -->
<Grid Grid.Row="1">
<!-- Tab Bar -->
<Grid RowDefinitions="Auto,*">
<HorizontalStackLayout
x:Name="TabBar"
Spacing="0"
BackgroundColor="{AppThemeBinding Light=#F5F5F5, Dark=#2C2C2E}"
onboard:Onboarding.StepKey="tab-bar"
onboard:Onboarding.Title="Tab Navigation"
onboard:Onboarding.Description="The tour will automatically switch tabs to show you different features."
onboard:Onboarding.Order="2">
<Button
x:Name="HomeTabBtn"
Text="Home"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
CornerRadius="0"
WidthRequest="120"
Clicked="OnHomeTabClicked" />
<Button
x:Name="SettingsTabBtn"
Text="Settings"
BackgroundColor="{AppThemeBinding Light=#E0E0E0, Dark=#3A3A3C}"
TextColor="{AppThemeBinding Light=#333333, Dark=#FFFFFF}"
CornerRadius="0"
WidthRequest="120"
Clicked="OnSettingsTabClicked" />
<Button
x:Name="ProfileTabBtn"
Text="Profile"
BackgroundColor="{AppThemeBinding Light=#E0E0E0, Dark=#3A3A3C}"
TextColor="{AppThemeBinding Light=#333333, Dark=#FFFFFF}"
CornerRadius="0"
WidthRequest="120"
Clicked="OnProfileTabClicked" />
</HorizontalStackLayout>
<!-- Tab Content -->
<Grid Grid.Row="1" BackgroundColor="{AppThemeBinding Light=#FFFFFF, Dark=#1C1C1E}">
<!-- Home Tab Content -->
<VerticalStackLayout
x:Name="HomeContent"
Padding="20"
Spacing="15"
IsVisible="True">
<Label Text="Home" FontSize="20" FontAttributes="Bold" />
<Border
Padding="15"
StrokeShape="RoundRectangle 8"
BackgroundColor="{AppThemeBinding Light=#E3F2FD, Dark=#1A2F3D}"
Stroke="{AppThemeBinding Light=Transparent, Dark=#2A4A5C}"
onboard:Onboarding.StepKey="home-card"
onboard:Onboarding.Title="Home Dashboard"
onboard:Onboarding.Description="This is your home dashboard. Next, we'll automatically switch to the Settings tab!"
onboard:Onboarding.Order="3">
<VerticalStackLayout Spacing="5">
<Label Text="Welcome back!" FontAttributes="Bold" />
<Label Text="Your dashboard shows key metrics and quick actions."
TextColor="{AppThemeBinding Light=Gray, Dark=#ABABAB}" />
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
<!-- Settings Tab Content -->
<VerticalStackLayout
x:Name="SettingsContent"
Padding="20"
Spacing="15"
IsVisible="False">
<Label Text="Settings" FontSize="20" FontAttributes="Bold" />
<Border
Padding="15"
StrokeShape="RoundRectangle 8"
x:Name="SettingsCard"
BackgroundColor="{AppThemeBinding Light=#FFF3E0, Dark=#3D2E1A}"
Stroke="{AppThemeBinding Light=Transparent, Dark=#5C4A2A}"
onboard:Onboarding.StepKey="settings-card"
onboard:Onboarding.Title="Settings Panel"
onboard:Onboarding.Description="The tour switched to Settings automatically! OnEntering action changed the tab."
onboard:Onboarding.Order="4">
<VerticalStackLayout Spacing="5">
<Label Text="App Settings" FontAttributes="Bold" />
<Label Text="Configure notifications, theme, and preferences."
TextColor="{AppThemeBinding Light=Gray, Dark=#ABABAB}" />
</VerticalStackLayout>
</Border>
<Border
Padding="15"
StrokeShape="RoundRectangle 8"
BackgroundColor="{AppThemeBinding Light=#FFEBEE, Dark=#3D1A1A}"
Stroke="{AppThemeBinding Light=Transparent, Dark=#5C2A2A}"
onboard:Onboarding.StepKey="premium-feature"
onboard:Onboarding.Title="Premium Feature"
onboard:Onboarding.Description="This step can be conditionally skipped using the StepEntering event."
onboard:Onboarding.Order="5">
<VerticalStackLayout Spacing="5">
<Label Text="Premium Settings" FontAttributes="Bold"
TextColor="{AppThemeBinding Light=#D32F2F, Dark=#EF5350}" />
<Label Text="Advanced features for premium users."
TextColor="{AppThemeBinding Light=Gray, Dark=#ABABAB}" />
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
<!-- Profile Tab Content -->
<VerticalStackLayout
x:Name="ProfileContent"
Padding="20"
Spacing="15"
IsVisible="False">
<Label Text="Profile" FontSize="20" FontAttributes="Bold" />
<Border
Padding="15"
StrokeShape="RoundRectangle 8"
BackgroundColor="{AppThemeBinding Light=#E8F5E9, Dark=#1B3D1F}"
Stroke="{AppThemeBinding Light=Transparent, Dark=#2E5432}"
onboard:Onboarding.StepKey="profile-card"
onboard:Onboarding.Title="Your Profile"
onboard:Onboarding.Description="Finally, we switched to the Profile tab. This is the last step!"
onboard:Onboarding.Order="6">
<VerticalStackLayout Spacing="5">
<Label Text="User Profile" FontAttributes="Bold" />
<Label Text="View and edit your profile information."
TextColor="{AppThemeBinding Light=Gray, Dark=#ABABAB}" />
</VerticalStackLayout>
</Border>
</VerticalStackLayout>
</Grid>
</Grid>
<!-- Side Panel (Drawer simulation) -->
<Border
x:Name="SidePanel"
Padding="20"
StrokeThickness="0"
IsVisible="False"
HorizontalOptions="Start"
VerticalOptions="Fill"
WidthRequest="280"
BackgroundColor="{AppThemeBinding Light=White, Dark=#2C2C2E}"
Shadow="{Shadow Brush=Black, Opacity=0.3, Offset='5,0'}">
<VerticalStackLayout Spacing="15">
<Label Text="Side Panel" FontSize="18" FontAttributes="Bold" />
<Label Text="This panel was opened by a step action!"
TextColor="{AppThemeBinding Light=Gray, Dark=#ABABAB}" />
<Button Text="Close" Clicked="OnClosePanelClicked"
BackgroundColor="{AppThemeBinding Light=#EEEEEE, Dark=#3A3A3C}"
TextColor="{AppThemeBinding Light=#333333, Dark=#FFFFFF}" />
</VerticalStackLayout>
</Border>
</Grid>
<!-- Footer with Controls -->
<Border Grid.Row="2" Padding="20" StrokeThickness="0"
BackgroundColor="{AppThemeBinding Light=#F5F5F5, Dark=#2C2C2E}">
<VerticalStackLayout Spacing="12">
<HorizontalStackLayout Spacing="10" HorizontalOptions="Center">
<Switch x:Name="SkipPremiumSwitch" IsToggled="False" />
<Label Text="Skip Premium Step" VerticalOptions="Center" />
</HorizontalStackLayout>
<Button
x:Name="StartTourBtn"
Text="Start Step Actions Tour"
Clicked="OnStartTourClicked"
BackgroundColor="{AppThemeBinding Light={StaticResource Primary}, Dark=#1E88E5}"
TextColor="White"
FontAttributes="Bold" />
<Border Padding="10" StrokeShape="RoundRectangle 6"
BackgroundColor="{AppThemeBinding Light=#FAFAFA, Dark=#1C1C1E}"
Stroke="{AppThemeBinding Light=#E0E0E0, Dark=#3A3A3C}">
<Label
x:Name="LogLabel"
Text="Action log will appear here..."
FontSize="11"
FontFamily="Consolas"
TextColor="{AppThemeBinding Light=#666666, Dark=#ABABAB}"
LineBreakMode="WordWrap" />
</Border>
</VerticalStackLayout>
</Border>
<!-- Onboarding Host -->
<onboard:OnboardingHost
x:Name="TourHost"
Grid.RowSpan="3"
ShowCornerNavigator="True"
ShowSkipButton="True"
DimOpacity="0.75"
Theme="System" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,148 @@
using MarketAlly.SpotlightTour.Maui;
namespace Test.SpotlightTour.Pages;
public partial class StepActionsPage : ContentPage
{
private readonly List<string> _actionLog = new();
public StepActionsPage()
{
InitializeComponent();
SetupStepActions();
}
private void SetupStepActions()
{
// Method 1: Register actions by StepKey (centralized approach)
TourHost.RegisterStepEnteringAction("settings-card", async (step, token) =>
{
LogAction("Entering settings-card: Switching to Settings tab...");
// Switch to Settings tab before spotlight appears
await MainThread.InvokeOnMainThreadAsync(() => SwitchToTab(1));
await Task.Delay(150); // Wait for UI to update
});
TourHost.RegisterStepEnteringAction("profile-card", async (step, token) =>
{
LogAction("Entering profile-card: Switching to Profile tab...");
// Switch to Profile tab
await MainThread.InvokeOnMainThreadAsync(() => SwitchToTab(2));
await Task.Delay(150);
});
// Method 2: Attach action directly to element via attached property
Onboarding.SetOnEntering(SettingsCard, async (step, token) =>
{
LogAction("OnEntering attached property executed for SettingsCard");
await Task.CompletedTask;
});
// Method 3: Use events for cross-cutting concerns
TourHost.StepEntering += async (sender, e) =>
{
LogAction($"StepEntering event: {e.Step.StepKey} (index {e.StepIndex})");
// Conditional skip: Skip premium step if toggle is on
if (e.Step.StepKey == "premium-feature" && SkipPremiumSwitch.IsToggled)
{
LogAction("Skipping premium-feature step (toggle is ON)");
e.Skip = true;
}
await Task.CompletedTask;
};
TourHost.StepEntered += (sender, e) =>
{
LogAction($"StepEntered event: {e.Step.StepKey} is now visible");
};
TourHost.StepLeaving += async (sender, e) =>
{
LogAction($"StepLeaving event: Leaving {e.Step.StepKey}");
// Example: Close side panel when leaving any step
if (SidePanel.IsVisible)
{
await MainThread.InvokeOnMainThreadAsync(() => SidePanel.IsVisible = false);
}
await Task.CompletedTask;
};
// Tour lifecycle events
TourHost.TourCompleted += (s, e) =>
{
LogAction("Tour completed!");
MainThread.BeginInvokeOnMainThread(() => SwitchToTab(0)); // Reset to Home
};
TourHost.TourSkipped += (s, e) =>
{
LogAction("Tour skipped");
MainThread.BeginInvokeOnMainThread(() => SwitchToTab(0)); // Reset to Home
};
}
private void SwitchToTab(int index)
{
// Get theme-aware colors
var isDark = Application.Current?.RequestedTheme == AppTheme.Dark;
var activeColor = Color.FromArgb("#512BD4"); // Primary
var inactiveColor = isDark ? Color.FromArgb("#3A3A3C") : Color.FromArgb("#E0E0E0");
var activeTextColor = Colors.White;
var inactiveTextColor = isDark ? Colors.White : Color.FromArgb("#333333");
// Update tab button styles
HomeTabBtn.BackgroundColor = index == 0 ? activeColor : inactiveColor;
HomeTabBtn.TextColor = index == 0 ? activeTextColor : inactiveTextColor;
SettingsTabBtn.BackgroundColor = index == 1 ? activeColor : inactiveColor;
SettingsTabBtn.TextColor = index == 1 ? activeTextColor : inactiveTextColor;
ProfileTabBtn.BackgroundColor = index == 2 ? activeColor : inactiveColor;
ProfileTabBtn.TextColor = index == 2 ? activeTextColor : inactiveTextColor;
// Show/hide content
HomeContent.IsVisible = index == 0;
SettingsContent.IsVisible = index == 1;
ProfileContent.IsVisible = index == 2;
}
private void LogAction(string message)
{
var timestamp = DateTime.Now.ToString("HH:mm:ss.fff");
_actionLog.Add($"[{timestamp}] {message}");
// Keep only last 5 entries
while (_actionLog.Count > 5)
_actionLog.RemoveAt(0);
MainThread.BeginInvokeOnMainThread(() =>
{
LogLabel.Text = string.Join("\n", _actionLog);
});
System.Diagnostics.Debug.WriteLine($"[StepActions] {message}");
}
private async void OnStartTourClicked(object? sender, EventArgs e)
{
_actionLog.Clear();
LogAction("Starting tour...");
// Reset to Home tab
SwitchToTab(0);
var result = await TourHost.StartTourAsync(this.Content);
LogAction($"Tour ended with result: {result}");
}
private void OnHomeTabClicked(object? sender, EventArgs e) => SwitchToTab(0);
private void OnSettingsTabClicked(object? sender, EventArgs e) => SwitchToTab(1);
private void OnProfileTabClicked(object? sender, EventArgs e) => SwitchToTab(2);
private void OnClosePanelClicked(object? sender, EventArgs e) => SidePanel.IsVisible = false;
}