Skip to content
Skip
3k

NFC

NFC (Near Field Communication) support for Skip apps on both iOS and Android.

SkipNFC provides a unified Swift API for NFC tag reading, writing, and communication. On iOS it wraps Apple’s CoreNFC framework. On Android, the Swift code is transpiled to Kotlin and uses the android.nfc APIs.

Supported capabilities:

  • NDEF message reading and writing on all tag types
  • Tag type detection: ISO-DEP (ISO 14443-4), NFC-V (ISO 15693), NFC-F (FeliCa), and MIFARE Classic
  • Tag UID access for identifying individual tags
  • NDEF record creation for text, URI, and MIME type payloads
  • NDEF record parsing with convenience accessors for text and URL content
  • Raw transceive for sending low-level commands to tags
  • Error handling with typed NFCError cases
  • Polling options for selecting which NFC technologies to scan for

Add the dependency to your Package.swift file:

let package = Package(
name: "my-package",
products: [
.library(name: "MyProduct", targets: ["MyTarget"]),
],
dependencies: [
.package(url: "https://source.skip.dev/skip-nfc.git", "0.0.0"..<"2.0.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
.product(name: "SkipNFC", package: "skip-nfc")
])
]
)

Add android.permission.NFC to your AndroidManifest.xml:

<uses-permission android:name="android.permission.NFC" />

Add the following to your entitlements and Info.plist:

<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
<string>TAG</string>
</array>
<key>NFCReaderUsageDescription</key>
<string>This app requires access to NFC to read and write data to NFC tags.</string>

The simplest use case is scanning for NDEF messages. Create an NFCAdapter and call startScanning with a message handler:

import SkipNFC
let adapter = NFCAdapter()
adapter.startScanning(messageHandler: { message in
for record in message.records {
print("Record type: \(record.typeName)")
if let text = record.textContent {
print("Text: \(text)")
}
if let url = record.urlContent {
print("URL: \(url)")
}
}
}, errorHandler: { error in
print("NFC error: \(error)")
})
// When done:
adapter.stopScanning()

To interact with tags directly (read, write, or send commands), use the tagHandler:

let adapter = NFCAdapter(pollingOptions: [.iso14443, .iso15693])
adapter.alertMessage = "Hold your device near the NFC tag"
adapter.startScanning(tagHandler: { tag in
print("Tag UID: \(tag.identifier.map { String(format: "%02X", $0) }.joined(separator: ":"))")
Task {
do {
let message = try await tag.readMessage()
for record in message.records {
print("Record: \(record.textContent ?? "unknown")")
}
} catch {
print("Failed to read: \(error)")
}
}
})

Create NDEF records and write them to a tag:

adapter.startScanning(tagHandler: { tag in
Task {
do {
let textRecord = NDEFRecord.makeTextRecord(text: "Hello from Skip!")
let uriRecord = NDEFRecord.makeURIRecord(url: "https://skip.dev")
let message = NDEFMessage.makeMessage(records: [textRecord, uriRecord])
try await tag.writeMessage(message)
print("Write successful")
} catch NFCError.tagReadOnly {
print("Tag is read-only")
} catch NFCError.tagNotNDEF {
print("Tag does not support NDEF")
} catch {
print("Write failed: \(error)")
}
}
})

SkipNFC provides factory methods for creating common NDEF record types:

// Text record with language code
let text = NDEFRecord.makeTextRecord(text: "Bonjour", locale: "fr")
// URI record
let uri = NDEFRecord.makeURIRecord(url: "https://skip.dev")
// MIME type record with arbitrary data
let json = NDEFRecord.makeMIMERecord(type: "application/json", data: jsonData)
// Compose into a message
let message = NDEFMessage.makeMessage(records: [text, uri, json])

Read the contents of NDEF records:

for record in message.records {
switch record.typeName {
case .nfcWellKnown:
// Text or URI record
if let text = record.textContent {
print("Text: \(text)")
} else if let url = record.urlContent {
print("URL: \(url)")
}
case .media:
// MIME type record
let mimeType = String(data: record.type, encoding: .utf8) ?? ""
print("MIME: \(mimeType), payload: \(record.payload.count) bytes")
default:
print("Other record type: \(record.typeName)")
}
}

For advanced use cases, send raw commands to a tag using transceive:

adapter.startScanning(pollingOptions: [.iso14443], tagHandler: { tag in
guard let isoTag = tag as? NFCISODepTag else { return }
Task {
do {
// Send an APDU command
let command = Data([0x00, 0xA4, 0x04, 0x00])
let response = try await isoTag.transceive(data: command)
print("Response: \(response.map { String(format: "%02X", $0) }.joined())")
} catch {
print("Transceive failed: \(error)")
}
}
})
import SwiftUI
import SkipNFC
struct NFCScannerView: View {
@State var adapter = NFCAdapter()
@State var scannedText: String = ""
@State var isScanning = false
var body: some View {
VStack(spacing: 16) {
Text(scannedText.isEmpty ? "Tap Scan to read an NFC tag" : scannedText)
.padding()
Button(isScanning ? "Stop" : "Scan") {
if isScanning {
adapter.stopScanning()
isScanning = false
} else {
adapter.alertMessage = "Hold your device near the NFC tag"
adapter.startScanning(messageHandler: { message in
for record in message.records {
if let text = record.textContent {
scannedText = text
} else if let url = record.urlContent {
scannedText = url.absoluteString
}
}
}, errorHandler: { error in
scannedText = "Error: \(error)"
})
isScanning = true
}
}
}
.padding()
}
}

The main interface for NFC scanning.

Property / MethodDescription
init(pollingOptions:)Create an adapter, optionally specifying which NFC technologies to scan for
isAvailable: BoolWhether NFC hardware is available on this device
isReady: BoolWhether NFC is enabled and ready for use
alertMessage: String?The iOS prompt message shown during scanning
startScanning(messageHandler:tagHandler:errorHandler:)Begin scanning for NFC tags
stopScanning()Stop scanning
OptionDescription
.iso14443ISO/IEC 14443 Type A/B (IsoDep, NfcA, NfcB)
.iso15693ISO/IEC 15693 (NfcV)
.iso18092NFC-F / FeliCa
.pacePACE (iOS only)
Property / MethodDescription
makeMessage(records:)Create a message from an array of records
records: [NDEFRecord]The records in this message
Property / MethodDescription
makeTextRecord(text:locale:)Create a well-known text record
makeURIRecord(url:)Create a well-known URI record
makeMIMERecord(type:data:)Create a MIME type record
identifier: DataRecord identifier
type: DataRecord type
payload: DataRaw payload data
typeName: TypeNameThe type name format (.nfcWellKnown, .media, etc.)
textContent: String?Parse payload as text (nil if not a text record)
urlContent: URL?Parse payload as URL (nil if not a URI record)

All tag types conform to NFCTagImpl and provide:

Property / MethodDescription
identifier: DataThe tag’s unique identifier (UID)
readMessage() async throwsRead the NDEF message from the tag
writeMessage(_:) async throwsWrite an NDEF message to the tag
transceive(data:) async throwsSend a raw command and receive a response
Tag ClassNFC TechnologyiOS TypeAndroid Type
NFCISODepTagISO-DEP (ISO 14443-4)NFCISO7816TagIsoDep
NFCVTagNFC-V (ISO 15693)NFCISO15693TagNfcV
NFCFTagNFC-F (FeliCa)NFCFeliCaTagNfcF
NFCMTagMIFARE ClassicNFCMiFareTagMifareClassic

NFCISODepTag also provides historicalBytes: Data? from the tag’s answer-to-select response.

CaseDescription
.notAvailableNFC hardware is not available
.tagNotNDEFTag does not support NDEF
.tagReadOnlyTag is read-only
.readFailed(String)Read operation failed
.writeFailed(String)Write operation failed
.connectionFailed(String)Connection to tag failed
.transceiveFailed(String)Raw command failed
.sessionError(String)Session or system error

This project is a Swift Package Manager module that uses the Skip plugin to build the package for both iOS and Android.

The module can be tested using the standard swift test command or by running the test target for the macOS destination in Xcode, which will run the Swift tests as well as the transpiled Kotlin JUnit tests in the Robolectric Android simulation environment.

Parity testing can be performed with skip test, which will output a table of the test results for both platforms.