A lightweight UIKit based guide/onboarding ("coach marks") component for highlighting UI elements with a cut-out mask, optional companion views, and step-based orchestration.
- Highlight a single focus view with an animated mask transition (
GuideOverlayView) - Auto-tracking of focus view position changes, adapting to screen rotation, animations, and more
- Step-based guide orchestration (
GuideController+GuideStep) - Companion overlay UI via subclassing (
GuideBuddyView) - Customized auto-completion triggers (
GuideAutoCompleter,ControlEventCompleter,GestureRecognizerCompleter) - Pluggable step extensions via plugins and attachments (e.g., audio playback, analytics tracking, guide-shown flag persistence)
- Minimal intrusion, preserving your existing business logic
- iOS 12+
- Swift 5.0+
- UIKit
Perform all UIKit-related creation, configuration, presentation, step changes, dismissal, and refresh operations on the main thread. focusViewProvider is also called during these operations.
- In Xcode: File β Add Packagesβ¦
- Paste repo URL:
https://github.com/noodles1024/PolarisGuideKit - Select PolarisGuideKit
Add to your Podfile:
pod 'PolarisGuideKit'Then in your code:
import PolarisGuideKitimport UIKit
import PolarisGuideKit
final class MyViewController: UIViewController {
private var guide: GuideController?
override func viewDidLoad() {
super.viewDidLoad()
// Assume myButton has already been added to view.
let step1 = GuideStep()
step1.focusView = myButton
step1.buddyView = MyBuddyView()
step1.forwardsTouchEventsToFocusView = true
step1.completer = ControlEventCompleter(control: myButton, event: .touchUpInside)
let controller = GuideController(hostView: view, steps: [step1])
controller.onDismiss = { _, context in
print("Guide dismissed. reason=\(context.reason)")
}
// Neither view nor myButton needs a window during initialization.
guide = controller
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
guide?.show()
}
}Subclass GuideBuddyView, build your UI, and call requestNext() / requestSkip() when appropriate.
import UIKit
import PolarisGuideKit
final class MyBuddyView: GuideBuddyView {
private let nextButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
nextButton.setTitle("Next", for: .normal)
nextButton.addTarget(self, action: #selector(onNext), for: .touchUpInside)
addSubview(nextButton)
// layout your subviews...
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func updateLayout(referenceLayoutGuide layoutGuide: UILayoutGuide, focusView: UIView) {
super.updateLayout(referenceLayoutGuide: layoutGuide, focusView: focusView)
// Position your subviews relative to layoutGuide.
}
@objc private func onNext() {
requestNext()
}
}Create a custom FocusStyle to control highlight shape.
import UIKit
import PolarisGuideKit
struct MyRoundedStyle: FocusStyle {
func highlightPath(for focusView: UIView, frameInOverlay rect: CGRect) -> UIBezierPath? {
UIBezierPath(roundedRect: rect.insetBy(dx: -8, dy: -8), cornerRadius: 12)
}
func buddyLayoutGuide(for focusView: UIView, shadowView: GuideShadowView) -> UILayoutGuide {
shadowView.frameLayoutGuide
}
}When highlighting cells in UITableView or UICollectionView, cells may be reused after reloadData, causing the highlight to shift or disappear. Use focusViewProvider to dynamically obtain the correct cell:
let step = GuideStep()
// Use focusViewProvider for dynamic cell lookup
step.focusViewProvider = { [weak self] in
guard let self else { return nil }
var focusCell = self.tableView.cellForRow(at: targetIndexPath)
if focusCell == nil {
self.tableView.layoutIfNeeded()
focusCell = self.tableView.cellForRow(at: targetIndexPath)
}
return focusCell
}
step.buddyView = MyBuddyView()The closure is called whenever the highlight needs to be updated, ensuring it always targets the correct cell even after reloadData.
Note: For static views that don't change, you can still use
step.focusView = myViewdirectly.
PolarisGuideKit supports extending functionality through a plugin system. Plugins can execute custom logic at different stages of the guide flow, while attachments store per-step data.
Audio Plugin: Add background music or voice narration to steps
let step = GuideStep()
step.focusView = myButton
// Add audio attachment
if let url = Bundle.main.url(forResource: "guide_step_1", withExtension: "mp3") {
step.addAttachment(GuideAudioAttachment(url: url, volume: 0.8))
}
// Enable audio plugin
let controller = GuideController(hostView: view, steps: [step], plugins: [AudioGuidePlugin()])Buddy views can adopt plugin-specific protocols when needed:
final class MyBuddyView: GuideBuddyView, GuideAudioEventReceiving {
func guideAudioDidStart() { /* update UI */ }
func guideAudioDidStop(didPlayToEnd: Bool) { /* update UI */ }
func guideAudioDidFail(_ error: Error) { /* update UI */ }
}You can create your own plugins by implementing the GuidePlugin protocol:
final class MyCustomPlugin: GuidePlugin {
func handle(_ event: GuideEvent, context: GuideStepContext) {
switch event {
case .guideWillShow:
// The guide is about to appear. Access its step through context.step.
break
case .stepDidHide:
// The step was hidden; this does not necessarily mean the user completed it.
break
case .guideDidHide:
// The guide was hidden.
break
default:
break
}
}
}
// Use custom plugin
let controller = GuideController(hostView: view, steps: steps, plugins: [MyCustomPlugin()])stepDidShow fires when the step is configured; guideDidShow fires after the overlay fade-in (if animated). Plugins that depend on fully visible UI should wait for guideDidShow.
Calling hide() sends the normal hide events and onDismiss. If a displayed controller is released directly, deinitialization cleans up resources without sending plugin lifecycle events or onDismiss. It calls each plugin's tearDown() instead (a no-op by default). Custom plugins that own players, timers, or observers should implement this method with idempotent cleanup that neither accesses the deinitializing controller nor invokes application callbacks. The built-in audio plugin stops playback without notifying the buddy view. Call hide() before releasing the controller if normal dismissal callbacks are needed.
flowchart TB
subgraph Core["Core Components"]
GuideController["GuideController<br/>(Orchestrator)"]
GuideStep["GuideStep<br/>(Step Configuration)"]
end
subgraph ViewHierarchy["View Hierarchy"]
GuideContainerView["GuideContainerView<br/>(Transparent Container)"]
GuideOverlayView["GuideOverlayView<br/>(Mask + Touch Forwarding)"]
MaskOverlayView["MaskOverlayView<br/>(Base Mask Class)"]
GuideBuddyView["GuideBuddyView<br/>(Companion UI)"]
GuideShadowView["GuideShadowView<br/>(Focus View Tracker)"]
end
subgraph Extensions["Extensions"]
FocusStyle["FocusStyle<br/>(Highlight Shape)"]
GuideAutoCompleter["GuideAutoCompleter<br/>(Completion Trigger)"]
GuidePlugin["GuidePlugin<br/>(Lifecycle Hooks)"]
GuideStepAttachment["GuideStepAttachment<br/>(Plugin Data)"]
end
GuideController -->|"manages"| GuideStep
GuideController -->|"creates & hosts"| GuideContainerView
GuideController -->|"dispatches events"| GuidePlugin
GuideContainerView -->|"contains"| GuideOverlayView
GuideContainerView -->|"contains"| GuideBuddyView
GuideOverlayView -.->|"inherits"| MaskOverlayView
GuideOverlayView -->|"creates"| GuideShadowView
GuideOverlayView -->|"uses"| FocusStyle
GuideStep -->|"configures"| GuideBuddyView
GuideStep -->|"uses"| FocusStyle
GuideStep -->|"triggers via"| GuideAutoCompleter
GuideStep -->|"carries"| GuideStepAttachment
- Ensure
step.forwardsTouchEventsToFocusView = true - Ensure the focus view is in the same window as the overlay
- Add the recognizer to
step.gestureRecognizerWhitelist - Gestures attached to table/collection views also need to be whitelisted
The whitelist is evaluated for each new touch sequence using the current step. A gesture can be blocked in step 1, allowed in step 2, and blocked again in step 3. Allowing one gesture does not release navigation swipe-back or other external gestures. Hiding or releasing the guide removes its blockers without changing the external recognizers' delegates or enabled states.
The Multi-Step Guide demo includes a vertical-pan counter to verify this sequence. Check that the count changes only in step 2, swipe-back stays blocked throughout the guide, and swipe-back works again after completing or skipping it.
Pass view, navigationController.view, or your app window according to the area the guide should cover. You can create and retain the controller in viewDidLoad with a nil focusView.window. Wait for that window only when you need to obtain it from the focus view to cover the entire window.
Host selection follows this order:
- An explicit
hostViewtakes priority. Atshow()time, the focus must belong to its view hierarchy. Both views can still be outside a window; scene activation is not required.isShown()reports attachment to the host, not whether the host is currently visible on screen. - When
hostView == nil, the current focus view'swindowis used. If the focus is not attached to a window yet,show()returnsfalseand can be retried. Once that window is available, no global key-window lookup is needed. - If a
focusViewProvideris configured but currently returnsnil, and no explicit host was supplied,show()returnsfalse. Retry after the cell or target view is attached. - Automatic window lookup is used only when no focus is configured, such as a buddy-only step. A scene-based app must have exactly one
.foregroundActivescene with the.windowApplicationrole and a suitable, unambiguous window in that scene. Noninteractive external-display scenes are excluded from this fallback. Supply an explicit host when multiple application scenes are active. On iOS 12, or in a legacy app with no connected scenes, the fallback uses a suitable window on the main screen while the app is active.
Initialization and addStep(_:) store step configuration without requiring the focus to be attached. show() and step transitions validate the host/focus hierarchy before creating constraints. An unavailable host or mismatched hierarchy returns false and can be retried later. The active-scene requirement applies only to the context-free automatic lookup in rule 4. Empty steps with neither a focus configuration nor a buddy view are still ignored.
Quick Start uses viewDidAppear as one possible presentation time. You can also call show() in viewDidLoad when the focus already belongs to the explicit host. If a navigation container has not attached the page yet, create the controller first and show it once that hierarchy is ready.
Each successful presentation stays bound to its original host. Changing the key window does not move the guide elsewhere or prevent hide() from cleaning up the original host. Disconnecting the bound scene automatically hides the guide and reports .programmatic through onDismiss. Screen mirroring uses the window containing your app UI; for a separate external-display interface, use that interface's focus view or an explicit host.
You can also use showStep(at:) or showNextStep() to select a starting step before calling show(). Preselection only records the step; it creates no cross-hierarchy constraints and sends no presentation events.
The standalone test project covers window selection, scene lifecycle, guide layout and interaction, and audio callbacks. It does not require the Demo's external dependencies. Run this command from the repository root:
xcodebuild -project Tests/PolarisGuideKitTests.xcodeproj \
-scheme PolarisGuideKitTests \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' testReplace iPhone 17 Pro with a simulator installed on your machine. The library continues to support iOS 12+; the test host requires iOS 13+ to exercise Window Scene behavior.
Licensed under the MIT License. See LICENSE.
If you find this project helpful, consider supporting my open source work β
Or support in other ways:
- β Star the project on GitHub
- π Create an Issue to report bugs or suggest features
- π Submit a Pull Request to contribute code
- π’ Share this project on social media

