How do you detect that a tag has left the field?
On Android, the next exchange throws TagLostException, and from API level 24 NfcAdapter.ignore() calls a listener once the tag has stayed away for a chosen delay. On iOS, isAvailable turns false and the next command fails. Neither reports the moment itself, and a tag that returns counts as a new one, the usual cause of double reads.
On Android
The ordinary flow has no removal event. The platform checks that a discovered tag is still present, and reader mode lets an app set how often through EXTRA_READER_PRESENCE_CHECK_DELAY, which the NfcAdapter reference describes as the delay the platform uses for presence checks. What the app sees is its next I/O call failing with TagLostException, a subclass of IOException.
isConnected() does not answer the question. The TagTechnology reference says it returns true when connect() has completed, close() has not been called and the tag is not known to be out of range, without any radio activity: it reports what the platform already knows, not a fresh check.
For a tag that stays in place, such as one in a car dock, NfcAdapter.ignore(), from API level 24, is the tool. It stops communication with the tag while it remains in range and calls an OnTagRemovedListener once the tag has been out of range for debounceMs; a tag that comes back sooner is not dispatched again. The reference adds three limits: the controller polls every 100 to 500 ms, so a tag going in and out of range can be dispatched again although it never really left; another tag cancels the call; and a tag with a random identifier defeats it.
On iOS
Every tag Core NFC delivers inherits from NFCNDEFTag, whose isAvailable turns false when the tag is removed from the field or disconnected, according to the isAvailable reference. No delegate call reports it: an app that needs to know reads the property, or learns it from a command that fails with readerTransceiveErrorTagConnectionLost.
A connection can also end while the tag stays. A tag remains connected until the app connects to another or restarts polling, according to the connect reference, and Apple's note on restartPolling() says tags detected before a restart are invalid.
An NDEF session does the waiting itself. With a delegate that receives messages, the session restarts polling once the detected tags are out of range, as the several tags page explains, so a tag held in place is not read twice in a row.
Why it is always late
Removal is inferred from silence. A phone learns that a tag has gone when a presence check or a command gets no answer, and the NfcAdapter reference puts the controller's polling interval at 100 to 500 ms. The app hears of the removal after the hand has moved, never at the moment it moves.
Design the interface around that lag. A write reported as done before its call returned is a guess: show completion when the call returns, and treat an exception as an unknown outcome, as the interrupted writes page explains.
Double reads start here
A tag that leaves and returns is a new discovery. On Android it arrives as a new Tag object, since the Tag reference creates one each time the tag comes into range; on iOS the session detects it again once it has left. The double trigger page describes how a hand produces this without meaning to.
An app that must act once per tap therefore decides what counts as a repeat. Comparing the identifier with the last one read, within a time window the app chooses, catches the common case, and ignore() does the same on Android for a tag left in place. Neither works with random identifiers, where the receiving side has to tolerate repeats, the conclusion the double trigger page reaches for servers.
Frequently asked questions
Does isConnected() tell me the tag is still there?
No. It reports whether connect has completed and close has not been called, without touching the radio. The next I/O call is what tells you.
Is there an event when a tag leaves on Android?
Only through NfcAdapter.ignore(), from API level 24, whose listener fires once the tag has been out of range for the delay you set.
Is there one on iOS?
No delegate call reports it. The tag's isAvailable property turns false, and the next command fails with a connection lost error.
Why does my app read the same tag twice?
The tag left the field and came back, which counts as a new discovery. Compare identifiers within a time window, or use ignore() on Android for a tag that stays in place.