😽

Core NFCでNDEFを読み書き(Swift)

2023/09/25に公開

はじめに

iOS 13 から Core NFC で書き込みもできるらしいのでやってみました。
(デリゲートとかあるので SwiftUI ではなく UIKit でやってます。)

SwiftUI 版はこちら。
https://zenn.dev/am10/articles/b2d29662b5b607

完成品はこんな感じ。
入力したテキストと固定値の URL を書き込んでいます。

nfc_uikit

ソース全体
import UIKit
import CoreNFC

final class ViewController: UIViewController {

    @IBOutlet private weak var textField: UITextField!
    private var session: NFCNDEFReaderSession!
    /// 書き込みモードフラグ
    private var isWriting = false
    private var ndefMessage: NFCNDEFMessage!

    override func viewDidLoad() {
        super.viewDidLoad()
        textField.delegate = self
    }

    @IBAction private func read(_ sender: Any) {
        isWriting = false
        startSession()
    }

    @IBAction private func write(_ sender: Any) {
        isWriting = true
        let textPayload = NFCNDEFPayload(
            format: NFCTypeNameFormat.nfcWellKnown,
            type: "T".data(using: .utf8)!,
            identifier: Data(),
            payload: textField.text!.data(using: .utf8)!
        )
        let uriPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: .init(string: "https://www.am10.blog/")!)
        ndefMessage = NFCNDEFMessage(records: [textPayload, uriPayload!])
        startSession()
    }

    private func startSession() {
        guard NFCNDEFReaderSession.readingAvailable else {
            showAlert(message: "デバイス対応してないよ( ̄∀ ̄)")
            return
        }

        session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
        session.alertMessage = "スキャン中"
        session.begin()
    }
}

extension ViewController: UITextFieldDelegate {

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        return true
    }
}

extension ViewController: NFCNDEFReaderSessionDelegate {

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
    }

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
    }

    // 必須ではないけどコンソールになんかでる
    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
        let tag = tags.first!
        session.connect(to: tag) { error in
            tag.queryNDEFStatus() { [unowned self] status, capacity, error in
                if self.isWriting {
                    // 書き込み
                    if status == .readWrite {
                        self.write(tag: tag, session: session)
                        return
                    }
                } else {
                    // 読み込み
                    if status == .readOnly || status == .readWrite {
                        self.read(tag: tag, session: session)
                        return
                    }
                }
                session.invalidate(errorMessage: "タグがおかしいよ(´∇`)")
            }
        }
    }

    private func write(tag: NFCNDEFTag, session: NFCNDEFReaderSession) {
        tag.writeNDEF(self.ndefMessage) { error in
            session.alertMessage = "書き込み完了♬(ノ゜∇゜)ノ♩"
            session.invalidate()
        }
    }

    private func read(tag: NFCNDEFTag, session: NFCNDEFReaderSession) {
        tag.readNDEF { [unowned self] message, error in
            session.alertMessage = "読み込み完了♬(ノ゜∇゜)ノ♩"
            session.invalidate()
            let text = message?.records.compactMap {
                switch $0.typeNameFormat {
                case .nfcWellKnown:
                    if let url = $0.wellKnownTypeURIPayload() {
                        return url.absoluteString
                    }
                    if let text = String(data: $0.payload, encoding: .utf8) {
                        return text
                    }
                    return nil
                default:
                    return nil
                }
            }.joined(separator: "\n\n")
            DispatchQueue.main.async {
                self.showAlert(message: text ?? "何も取れなかったよ(゚∀゚)")
            }
        }
    }
}

extension UIViewController {

    func showAlert(message: String) {
        let alert = UIAlertController(title: "", message: message, preferredStyle: .alert)
        alert.addAction(.init(title: "OK", style: .default) { _ in } )
        present(alert, animated: true)
    }
}

環境

  • Xcode 15
  • iOS 17.0.1(iPhone 12 mini)
  • NFC タグ

事前準備

Signing & Capabilities から「Near Field Communication Tag Reading」を追加。

capabilities

Info.plist に「Privacy - NFC Scan Usage Description」を追加。

info

Storyboard にテキストフィールドとボタンを置いて紐づけておく。

storyboard

読み取り

読み取り処理は下記でできます。

    private var session: NFCNDEFReaderSession!

    @IBAction private func read(_ sender: Any) {
        startSession()
    }

    private func startSession() {
        guard NFCNDEFReaderSession.readingAvailable else {
            print("デバイス対応してないよ( ̄∀ ̄)")
            return
        }

        session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
        session.alertMessage = "スキャン中"
        session.begin()
    }

extension ViewController: NFCNDEFReaderSessionDelegate {

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
        session.alertMessage = "読み込み完了♬(ノ゜∇゜)ノ♩"
        session.invalidate()
        let text = messages.first?.records.compactMap {
            switch $0.typeNameFormat {
            case .nfcWellKnown:
                if let url = $0.wellKnownTypeURIPayload() {
                    return url.absoluteString
                }
                let payload = $0.wellKnownTypeTextPayload()
                if let text = payload.0, let locale = payload.1 {
                    return "\(text)\n\(locale)"
                }
                return nil
            default:
                return nil
            }
        }.joined(separator: "\n\n")
        print(text ?? "何も取れなかったよ(゚∀゚)")
    }

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
    }

    // 必須ではないけどコンソールになんかでる
    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
    }
}

NFCTypeNameFormat は下記のように色々あるみたいですが今回は nfcWellKnown のみ使います(これでテキストと URL がとれたので)。

public enum NFCTypeNameFormat : UInt8, @unchecked Sendable {

    @available(iOS 11.0, *)
    case empty = 0

    @available(iOS 11.0, *)
    case nfcWellKnown = 1

    @available(iOS 11.0, *)
    case media = 2

    @available(iOS 11.0, *)
    case absoluteURI = 3

    @available(iOS 11.0, *)
    case nfcExternal = 4

    @available(iOS 11.0, *)
    case unknown = 5

    @available(iOS 11.0, *)
    case unchanged = 6
}

ちょっと改善

読み取り処理は上記でも可能なのです書き込みに対応するため少し修正します。

書き込み処理のためには下記のデリゲートを実装する必要があります。

func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag])

上記メソッドを実装すると読み込み処理で利用していた下記メソッドが呼ばれなくなってしまいます。

func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage])

Important

The reader session doesn’t call this method when the delegate provides the readerSession(_:didDetect:) method.

参考:readerSession(_:didDetectNDEFs:)

書き込みにも対応するために下記のように修正します。

extension ViewController: NFCNDEFReaderSessionDelegate {

    // 必須(didDetect を実装すると呼ばれなくなる)
    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
    }

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
    }

    // 必須ではないけどコンソールになんかでる
    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
        let tag = tags.first!
        session.connect(to: tag) { error in
            tag.queryNDEFStatus() { [unowned self] status, capacity, error in
                    // 読み込み
                    if status == .readOnly || status == .readWrite {
                        self.read(tag: tag, session: session)
                        return
                }
                session.invalidate(errorMessage: "タグがおかしいよ(´∇`)")
            }
        }
    }

    private func read(tag: NFCNDEFTag, session: NFCNDEFReaderSession) {
        tag.readNDEF { [unowned self] message, error in
            session.alertMessage = "読み込み完了♬(ノ゜∇゜)ノ♩"
            session.invalidate()
            let text = message?.records.compactMap {
                switch $0.typeNameFormat {
                case .nfcWellKnown:
                    if let url = $0.wellKnownTypeURIPayload() {
                        return url.absoluteString
                    }
                    let payload = $0.wellKnownTypeTextPayload()
                    if let text = payload.0, let locale = payload.1 {
                        return "\(text)\n\(locale)"
                    }
                    return nil
                default:
                    return nil
                }
            }.joined(separator: "\n\n")
            print(text ?? "何も取れなかったよ(゚∀゚)")
        }
    }
}

これで読み取りは完了です。

書き込み

読み取り処理は下記でできます。

    @IBOutlet private weak var textField: UITextField!
    private var ndefMessage: NFCNDEFMessage!
    private var session: NFCNDEFReaderSession!

    private func startSession() {
        guard NFCNDEFReaderSession.readingAvailable else {
            showAlert(message: "デバイス対応してないよ( ̄∀ ̄)")
            return
        }

        session = NFCNDEFReaderSession(delegate: self, queue: nil, invalidateAfterFirstRead: false)
        session.alertMessage = "スキャン中"
        session.begin()
    }

    @IBAction private func write(_ sender: Any) {
        let textPayload = NFCNDEFPayload.wellKnownTypeTextPayload(
            string: textField.text!,
            locale: Locale(identifier: "ja_JP")
        )
        let uriPayload = NFCNDEFPayload.wellKnownTypeURIPayload(url: .init(string: "https://www.am10.blog/")!)
        ndefMessage = NFCNDEFMessage(records: [textPayload!, uriPayload!])
        startSession()
    }

extension ViewController: NFCNDEFReaderSessionDelegate {

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
    }

    // 必須
    func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
    }

    // 必須ではないけどコンソールになんかでる
    func readerSessionDidBecomeActive(_ session: NFCNDEFReaderSession) {
    }

    func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
        let tag = tags.first!
        session.connect(to: tag) { error in
            tag.queryNDEFStatus() { [unowned self] status, capacity, error in
                if status == .readWrite {
                    self.write(tag: tag, session: session)
                    return
                }
               
                session.invalidate(errorMessage: "タグがおかしいよ(´∇`)")
            }
        }
    }

    private func write(tag: NFCNDEFTag, session: NFCNDEFReaderSession) {
        tag.writeNDEF(self.ndefMessage) { error in
            session.alertMessage = "書き込み完了♬(ノ゜∇゜)ノ♩"
            session.invalidate()
        }
    }
}

NFCNDEFMessage に値を設定して tag.writeNDEF(:) で書き込むだけです。

下記を ViewController に持たせているのはデリゲートメソッド内で UITextField にアクセスするとメインスレッドではないと警告が出たためボタン押下時に生成処理をしています。

private var ndefMessage: NFCNDEFMessage!

テキストの読み書きちょっと改善

上記で問題なく読み書きできたのですが書き込んだテキストデータを他のアプリで読み込んでみたところ文字化けしてしまいました(URL は表示されました)。

他のアプリでも読み込めるようにテキストの読み書き処理を下記のように改善しました。

// 読み込み処理
// (private func read(tag: NFCNDEFTag, session: NFCNDEFReaderSession)の処理を修正)
case .nfcWellKnown:
    if let text = String(data: $0.payload, encoding: .utf8) {
        return text
    }
    return nil


// 書き込み処理
// (@IBAction private func write(_ sender: Any)の処理を修正)
let textPayload = NFCNDEFPayload(
    format: NFCTypeNameFormat.nfcWellKnown,
    type: "T".data(using: .utf8)!,
    identifier: Data(),
    payload: textField.text!.data(using: .utf8)!
)

これで他のアプリでもテキストを読み込めるようになりました。

おわりに

今回はほとんどエラーハンドリングをしていないので実際にアプリを作成する際はそのあたりの処理も追加する必要があると思います。

とりあえずはこれで NFC タグに自由にテキストと URL を読み書きできるようになりました!

参考サイト

Discussion