Flutter NFC Development Guide: Cross-Platform with nfc_manager
Flutter's cross-platform story is compelling for NFC: write once, ship on Android and iOS from a single Dart codebase. The nfc_manager package by Naoki Okada is the de-facto standard, wrapping Android's NfcAdapter and iOS's Core NFC behind a unified API while still exposing platform-specific tech objects when you need raw access.
Package Setup
Add to pubspec.yaml:
dependencies:
nfc_manager: ^3.3.0
Run flutter pub get. No additional Kotlin/Swift bridge code is required for basic NDEF operations.
Android: Add to AndroidManifest.xml inside <manifest>:
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />
iOS: In ios/Runner/Info.plist, add:
<key>NFCReaderUsageDescription</key>
<string>Used to read and write NFC tags.</string>
Add the com.apple.developer.nfc.readersession.formats entitlement with value NDEF in ios/Runner/Runner.entitlements.
Availability Check
Always guard NFC calls with an availability check before presenting UI:
import 'package:nfc_manager/nfc_manager.dart';
Future<bool> isNfcAvailable() async {
return await NfcManager.instance.isAvailable();
}
On iOS, isAvailable() returns false on simulators and iPads without NFC hardware. On Android it reflects whether NFC is enabled in Settings.
Reading an NDEF Tag
The session-based API mirrors iOS Core NFC's sheet modal on iOS and fires silently on Android:
Future<void> readTag() async {
NfcManager.instance.startSession(
onDiscovered: (NfcTag tag) async {
final ndef = Ndef.from(tag);
if (ndef == null) {
// Tag is not NDEF-compatible
NfcManager.instance.stopSession(errorMessage: 'Tag is not NDEF.');
return;
}
final message = ndef.cachedMessage;
if (message == null) {
NfcManager.instance.stopSession(errorMessage: 'No NDEF message.');
return;
}
for (final record in message.records) {
_handleRecord(record);
}
NfcManager.instance.stopSession();
},
);
}
ndef.cachedMessage contains the message read during tag discovery — no extra round-trip required. Always call stopSession() in all code paths, including error branches; an open iOS session blocks the NFC sheet indefinitely.
Parsing NDEF Records
nfc_manager gives you raw NdefRecord objects. Parse ndef-uri and ndef-text records manually:
void _handleRecord(NdefRecord record) {
// URI record: TNF = 0x01, type = [0x55]
if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown &&
record.type.isNotEmpty &&
record.type[0] == 0x55) {
final prefix = _uriPrefixes[record.payload[0]] ?? '';
final uri = prefix + utf8.decode(record.payload.sublist(1));
debugPrint('URI: $uri');
return;
}
// Text record: TNF = 0x01, type = [0x54]
if (record.typeNameFormat == NdefTypeNameFormat.nfcWellknown &&
record.type.isNotEmpty &&
record.type[0] == 0x54) {
final langLen = record.payload[0] & 0x3F;
final text = utf8.decode(record.payload.sublist(1 + langLen));
debugPrint('Text: $text');
}
}
const _uriPrefixes = {
0x00: '', 0x01: 'http://www.', 0x02: 'https://www.',
0x03: 'http://', 0x04: 'https://', 0x05: 'tel:', 0x06: 'mailto:',
};
Use the NDEF Decoder to verify your parsing logic against known byte sequences.
Writing an NDEF Message
Future<void> writeUrl(String url) async {
NfcManager.instance.startSession(
onDiscovered: (NfcTag tag) async {
final ndef = Ndef.from(tag);
if (ndef == null || !ndef.isWritable) {
NfcManager.instance.stopSession(errorMessage: 'Tag not writable.');
return;
}
final record = NdefRecord.createUri(Uri.parse(url));
final message = NdefMessage([record]);
try {
await ndef.write(message);
NfcManager.instance.stopSession(alertMessage: 'Written!');
} catch (e) {
NfcManager.instance.stopSession(errorMessage: e.toString());
}
},
);
}
Check user-memory limits before writing large payloads. The Memory Calculator gives available bytes per chip family.
Platform-Specific Raw Access
For MIFARE operations or authentication beyond NDEF, access the tech objects directly:
// Android: MIFARE Ultralight raw page read
final mifareUltralight = MifareUltralight.from(tag);
if (mifareUltralight != null) {
final pages = await mifareUltralight.readPages(pageOffset: 4);
}
// Android: IsoDep for ISO 14443-4 APDU commands
final isoDep = IsoDep.from(tag);
if (isoDep != null) {
final response = await isoDep.transceive(data: Uint8List.fromList([0x00, 0xA4, 0x04, 0x00]));
}
iOS raw access is more restricted — Core NFC exposes NFCFeliCaTag, NFCMiFareTag, and NFCISO7816Tag through the nfc_manager iOS tech objects.
State Management Pattern
Wrap NFC state in a ChangeNotifier or Riverpod provider so the UI reacts to scan events without coupling to platform callbacks:
class NfcScanNotifier extends ChangeNotifier {
String? _lastUri;
bool _isScanning = false;
String? get lastUri => _lastUri;
bool get isScanning => _isScanning;
Future<void> startScan() async {
_isScanning = true;
notifyListeners();
NfcManager.instance.startSession(onDiscovered: (tag) async {
final ndef = Ndef.from(tag);
// ... parse ...
_isScanning = false;
notifyListeners();
NfcManager.instance.stopSession();
});
}
}
Error Handling Reference
| Exception | Cause | Fix |
|---|---|---|
NfcManagerException(unavailable) |
NFC disabled or absent | Show settings deeplink |
NfcManagerException(sessionTimeout) |
iOS 60s timeout | Restart session, show hint |
IOException: Tag was lost |
Tag moved too fast | Prompt user to hold steady |
FormatException |
Corrupt NDEF on tag | Re-format tag |
TagLostException |
Android mid-write tag loss | Retry with fresh tag |
See also: iOS Core NFC Programming | Android NFC Programming | Web NFC API Guide | NDEF Specification Deep Dive