An on-device iOS Swift package that tells a user exactly where to tap their phone against an NFC reader, tag, or smart lock.
It resolves the antenna's physical location through a layered, confidence-aware chain — your own remote catalog, a bundled offline seed catalog, and a device-shape heuristic that never fails — and ships SwiftUI components to draw the result as a marker or a guided sweep animation.
There is no public Core NFC API that reports where an NFC antenna physically sits on the
running device. NFCReaderSession/NFCTagReaderSession expose tag reading, never antenna
geometry, and Apple has never needed to expose one — Core NFC already draws a system "hold near
the top of iPhone" sheet during a scan.
Because of this, the resolver chain has three layers:
- Remote catalog (host-supplied, cache-first)
- Bundled offline seed catalog
- Form-factor heuristic — always succeeds
Confidence.exact is still reachable — via a vendor/community-verified catalog entry — just
never via a live on-device measurement. See DECISIONS.md for the full list of
design decisions and the reasoning behind each one.
- Layered resolver chain, most-confident-first: remote catalog → bundled offline catalog → a form-factor heuristic that always succeeds, so the library never returns "no answer."
- Confidence is explicit, never hidden: every result carries
Confidence.exact/.approximate/.generic/.unknown, and the bundled UI draws a solid marker only for trustworthy results — a dashed, sweeping highlight otherwise. - Bring your own backend, analytics, and logging: the package defines the protocols
(
CatalogRemoteAPI,NFCLocatorAnalytics,NFCLocatorLogger); you implement them against your existing stack. The package itself makes no network calls and ships no analytics SDK. - Works fully offline out of the box via the bundled seed catalog and heuristic fallback.
- Foldable/tablet aware at the model level (
FormFactor/FoldStatecover foldables for catalog compatibility with the shared wire format), even though no shipping iPhone is a foldable today. - Accessible by default:
reducedMotionsupport throughout, accessibility labels on every interactive/informational element, and system-color-aware defaults — you supply the colors, the package never hardcodes brand hues. - Written in Swift, built on SwiftUI, SwiftData, and Swift Concurrency (
async/await).
- iOS 17+
- Swift 5.10+ / Xcode 15.3+
Local path (while developing alongside a checkout of this repo):
dependencies: [
.package(path: "../NFCLocatoriOS")
]Or add it as a remote dependency — in Xcode: File → Add Package Dependencies…, or in
Package.swift:
dependencies: [
.package(url: "https://github.com/nagarjunavs/NFCLocatoriOS.git", from: "0.1.0")
]pod 'NFCLocatorCore', '~> 0.1'Published to CocoaPods Trunk from NFCLocatorCore.podspec at the
repository root — see "Versioning & releasing" below for how new versions are cut.
The package deliberately does not own networking, analytics, or logging — it defines the protocols and leaves the implementation to you:
struct MyCatalogRemoteAPI: CatalogRemoteAPI {
func fetchCatalog(sinceVersion: Int) async throws -> CatalogResponseDTO {
// your networking stack here
}
}If you have no real backend yet, or want to ship fully offline, implement CatalogRemoteAPI to
throw unconditionally — any error is treated as "unavailable, fall through to the next source,"
never surfaced to the user. See the TapSense sample app's FakeCatalogRemoteAPI for exactly
this pattern.
let useCase = ResolveAntennaLocationUseCase(
remoteAPI: MyCatalogRemoteAPI(),
cache: SwiftDataCatalogCache(modelContainer: myContainer),
seedCatalogLoader: BundledSeedCatalogLoader(logger: myLogger),
analytics: myAnalytics,
logger: myLogger
)
let signals = DeviceIdentitySignals(
fingerprint: myFingerprintProvider.current(),
formFactor: .bar,
foldState: .notApplicable,
screenSizeClass: .compact
)
let profile = await useCase(signals)
let uiState = profile.toUIState()struct MyScreen: View {
let state: AntennaLocatorUIState
var body: some View {
AntennaLocatorScreen(state: state) {
// re-run the flow above
}
}
}AntennaLocatorScreen is a full, opinionated screen. If you want to lay out the marker
yourself alongside your own copy/branding, drop down to its two building blocks directly —
AntennaSilhouette for .resolvedMarker, GuidedSweepAnimation for `.fallbackGuidance``.
| Type | What it's for |
|---|---|
ResolveAntennaLocationUseCase |
The entry point — runs the resolver chain, returns a DeviceAntennaProfile. |
DeviceAntennaProfile.toUIState() |
Maps the raw resolved profile to the UI-shaped AntennaLocatorUIState. |
AntennaLocatorScreen |
Complete, batteries-included screen for the state above. |
AntennaSilhouette, GuidedSweepAnimation |
The individual SwiftUI components AntennaLocatorScreen composes. |
DeviceAntennaProfile, Confidence, NormalizedRect |
The resolved data: where, and how sure. |
CatalogRemoteAPI, NFCLocatorAnalytics, NFCLocatorLogger |
The three seams you implement. |
DeviceFingerprintProvider |
Override device identification (e.g. a phone-picker/preview screen) instead of the real running device. |
Package.swift lives at the repository root (not in this NFCLocatorCore/ folder), so run these
from there:
swift build
swift build -c release
swift testFollows Semantic Versioning: breaking changes to any public/open API
bump the major version, additive changes bump minor, fixes bump patch. Unreleased changes are
tracked in ../CHANGELOG.md.
To cut a release (owner action — not automated by this repo):
- Update
[Unreleased]inCHANGELOG.mdto the new version + date. - Bump
s.versioninNFCLocatorCore.podspecto match. - Commit, then tag:
git tag <version> && git push origin <version>(the tag must match the podspec'ss.versionexactly — CocoaPods resolvessource_filesfrom that git tag). pod trunk push NFCLocatorCore.podspecto publish to CocoaPods (requirespod trunk registeronce, first time — see CocoaPods' own docs). No--allow-warningsneeded; the podspec lints clean.- Swift Package Index picks up new tags automatically once the repository is registered there (see https://swiftpackageindex.com/add-a-package) — no separate publish step for SPM itself.
- "No such module 'NFCLocatorCore'" after adding via SPM: File → Packages → Reset Package Caches in Xcode, then rebuild.
- Localized strings/seed catalog missing under CocoaPods: confirm the consuming app's
Podfilehas runpod install(not justpod update) since the resource bundle changed — CocoaPods resource bundles are wired atpod installtime. - SwiftData model container errors on first run:
SwiftDataCatalogCache.makeModelContainer()throws if the on-disk schema is incompatible with a previous version; see its doc comment for the migration story before working around it with a manual store deletion.
MIT — see LICENSE.