← Back to list

SwiftUI: Locate Peers with Nearby Interaction Framework

Let’s make a simple peer discovery app!

Itsuki in Level Up Coding · 2025-02-23 08:18 · 66 claps · 19.6 min read
#peer-to-peer #p2p #swiftui #multipeerconnectivity #ios-app-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

SwiftUI: Locate Peers with Nearby Interaction Framework

***Nearby Interaction*** Framework allows us to get the position of other devices with the Ultra Wide-band (UWB) chip (High Tech~)! That can be another iPhone (11 or later), Apple Watch, or even third-party accessories.

Of course, what we can do is more than just locating!

Because technically speaking, what we have here is using OUR OWN DEVICE to control what is shown on OTHER DEVICES!

For example, we can create a multiplayer game where the user get to use their own device as a (simple) game controller to control the gameplay on peer’s device.

We can even combine Nearby Interaction with ARKit for more precise measurements and more interesting usages. Here is an ***example*** provided by Apple if you want to check it out.

It is really cool!

Anyway!

In this article, we will start simple! Checking out how we can locate our peer’s device with Nearby interaction. Specifically, we will be targeting iPhones.

Demo code available on **GitHub**! Like Always!

Overview

Let’s start with a high level overview of what information we can obtain using Nearby Interaction and the steps to that.

Information available

  1. [distance](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601348-distance): distance from the user’s device to the peer device in meters
  2. [direction](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601347-direction): A [simd_float3](https://developer.apple.com/documentation/simd/simd_float3) vector that points from the user’s device in the direction of the peer device.
  3. [horizontalAngle](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/horizontalangle-hsg): An angle in radians that indicates the azimuthal direction to the nearby object.
  4. [verticalDirectionEstimate](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/verticaldirectionestimate-swift.property): The estimation of a nearby object’s vertical position as it relates to the user’s device.

[distance](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601348-distance) and [direction](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601347-direction) are the basic ones. For peer device out of range, both value will be nil. If the device is out of the line of sight, [direction](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601347-direction) will be nil.

We then have the [horizontalAngle](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/horizontalangle-hsg) and [verticalDirectionEstimate](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/verticaldirectionestimate-swift.property) that will only be available if [isCameraAssistanceEnabled](https://developer.apple.com/documentation/nearbyinteraction/ninearbypeerconfiguration/iscameraassistanceenabled) is true, telling the framework to use ***ARKit*** to provide a nearby object’s [distance](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/distance-9atp7) and [direction](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/direction-4qh5w) in a wider range of environmental conditions.

When we don’t have [isCameraAssistanceEnabled](https://developer.apple.com/documentation/nearbyinteraction/ninearbypeerconfiguration/iscameraassistanceenabled) to true, we can also calculate the azimuthal and vertical direction between the devices from the [simd_float3](https://developer.apple.com/documentation/simd/simd_float3) [direction](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/3601347-direction) like following.

extension NINearbyObject {
    var azimuth: Float? {
        if let horizontalAngle {
            return horizontalAngle
        }
        guard let direction else {
            return nil
        }
        return asin(direction.x)
    }

    var elevation: Float? {
        guard let direction else {
            return nil
        }
        return atan2(direction.z, direction.y) + .pi / 2
    }
}

Nearby Interaction works best when peer iPhone devices are:

  • Within 9 meters of each other
  • In portrait orientation
  • Facing each other with their back camera

as demonstrated by the following image.

https://developer.apple.com/documentation/nearbyinteraction/initiating-and-maintaining-a-session#Coach-the-user-on-range-orientation-and-line-of-sight

https://developer.apple.com/documentation/nearbyinteraction/initiating-and-maintaining-a-session#Coach-the-user-on-range-orientation-and-line-of-sight

Basic Steps

  1. Locate and connect with the peers with ***Core Bluetooth, [Multipeer Connectivity](https://developer.apple.com/documentation/MultipeerConnectivity), [Watch Connectivity](https://developer.apple.com/documentation/WatchConnectivity)***, or a custom server deployment
  2. Create an [NISession](https://developer.apple.com/documentation/nearbyinteraction/nisession) and assign [NISessionDelegate](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate)
  3. Exchange [discoveryToken](https://developer.apple.com/documentation/nearbyinteraction/nisession/discoverytoken)
  4. Create [NIConfiguration](https://developer.apple.com/documentation/nearbyinteraction/niconfiguration) from peer’s token using one of the subclasses: [NINearbyPeerConfiguration](https://developer.apple.com/documentation/nearbyinteraction/ninearbypeerconfiguration) or [NINearbyAccessoryConfiguration](https://developer.apple.com/documentation/nearbyinteraction/ninearbyaccessoryconfiguration)
  5. [run](https://developer.apple.com/documentation/nearbyinteraction/nisession/run(_:)) the configuration to start an interaction session
  6. Read peer’s position with the [session(_:didUpdate:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didupdate:)) method of NISessionDelegate

Set Up

Like ALWAYS! Permission related stuff first!

Common Prerequisite

First of all, we will need to add the [NSNearbyInteractionUsageDescription](https://developer.apple.com/documentation/bundleresources/information-property-list/nsnearbyinteractionusagedescription) key to our Info.plist. This will ask for user permission to begin an interaction session with nearby devices and prompt the reason for it.

Framework Specific

In addition, we will also need to add couple other keys depending on which framework we use to locate peers and exchange tokens. As I have mentioned above, this can be either ***Core Bluetooth, [Multipeer Connectivity](https://developer.apple.com/documentation/MultipeerConnectivity), [Watch Connectivity](https://developer.apple.com/documentation/WatchConnectivity)***, or a custom server deployment.

In this article, we will be using the ***Multipeer Connectivity***.

If you want to use Core Bluetooth instead, please feel free to check out my previous articles: ***Low Energy Bluetooth (Part1: Peripheral Side) and [Part2: Central Side](https://levelup.gitconnected.com/swiftui-low-energy-bluetooth-part2-central-side-1f3148217334)*** where we have token a super detailed look at the framework.

Please allow me to assume that you have some basic experiences with this ***Multipeer Connectivity framework. If you need a quick catch up on this, how we can find peers and exchange messages, please give my previous article: [SwiftUI: Peer-to-Peer (P2P) with Multipeer Connectivity Framework ](https://medium.com/@itsuki.enjoy/swiftui-peer-to-peer-p2p-with-multipeer-connectivity-framework-eb76f13e2b4e)***a look and hope that it can clarify some of the concept for you!

Two additional keys we need to add.

  1. [NSLocalNetworkUsageDescription](https://developer.apple.com/documentation/BundleResources/Information-Property-List/NSLocalNetworkUsageDescription)
  2. [NSBonjourServices](https://developer.apple.com/documentation/BundleResources/Information-Property-List/NSBonjourServices)

We have two items within the Bonjour services array. The first substring identifies the application protocol and the second identifies the transport protocol.

It needs to match the serviceType that we will be using later while creating [MCNearbyServiceAdvertiser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyserviceadvertiser) and [MCNearbyServiceBrowser](https://developer.apple.com/documentation/multipeerconnectivity/mcnearbyservicebrowser).

Implementation

Full Code

Code first! We will have a MultipeerManager class for managing Multipeer Connectivity, InteractionManager for Nearby Interaction, and a ViewModel wrapping those two together.

import SwiftUI
import MultipeerConnectivity
import NearbyInteraction

@MainActor
@Observable
class ViewModel {

    enum _Error: Error {

        // multipeer
        case invitationFailed(String)
        case startBrowsingFailed(String)
        case startAdvertisingFailed(String)
        case sendMessageFailed(String)
        case peerDisconnected
        case invalidMCSessionState

        // nearby interaction
        case deviceUnsupported
        case tokenCreationFailed
        case niSessionEnded(String)


        var message: String {
            switch self {
            case .invitationFailed(let message):
                return message
            case .startBrowsingFailed(let message):
                return message
            case .startAdvertisingFailed(let message):
                return message
            case .sendMessageFailed(let message):
                return message
            case .peerDisconnected:
                return "Peer disconnected. Please try to invite again."
            case .invalidMCSessionState:
                return "Peer not connected. Please invite to connect first."
            case .deviceUnsupported:
                return "Device does not supported nearby interaction."
            case .tokenCreationFailed:
                return "Failed to create discovery token for nearby interaction."
            case .niSessionEnded(let message):
                return message
            }
        }

    }

    var error: _Error? = nil {
        didSet {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
                self.error = nil
            })
        }
    }

    var coachingMessage: String? = nil {
        didSet {
            DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: {
                self.coachingMessage = nil
            })
        }
    }

    var multipeerManager = MultipeerManager()
    var interactionManager = InteractionManager()

    init() {

        // Multipeer related initializations
        multipeerManager.setError = self.setError
        multipeerManager.peerConnectedHandler = self.connectedToPeer
        multipeerManager.peerDisconnectedHandler = self.disconnectedFromPeer
        multipeerManager.peerDataHandler = self.dataReceivedHandler

        // interaction
        interactionManager.setError = self.setError
        interactionManager.setCoachingMessage = self.setCoachingMessage
    }
}

extension ViewModel {

    func sendInteractionRequest(to peerID: MCPeerID) {
        if multipeerManager.managedPeers[peerID] != .connected {
            self.error = .invalidMCSessionState
            return
        }
        interactionManager.shareDiscoveryToken(with: peerID, sendDateHandler: multipeerManager.send)
    }


    // peer token already received
    func acceptInteractionRequest(from peerID: MCPeerID) {
        if multipeerManager.managedPeers[peerID] != .connected {
            self.error = .invalidMCSessionState
            return
        }
        interactionManager.shareDiscoveryToken(with: peerID, sendDateHandler: multipeerManager.send)
        interactionManager.runConfiguration(with: peerID)
    }

    func reset() {
        self.interactionManager.reset()
        self.multipeerManager.reset()
    }

    private func setError(_ error: _Error) {
        print("error: \(error)")
        self.error = error
    }

    private func setCoachingMessage(_ message: String) {
        self.coachingMessage = message
    }

    private func connectedToPeer(_ peerID: MCPeerID) {
        interactionManager.initializePeer(peerID)
    }

    private func disconnectedFromPeer(_ peerID: MCPeerID) {
        self.error = .peerDisconnected
        interactionManager.invalidateInteraction(with: peerID)
    }

    private func dataReceivedHandler(_ peerID: MCPeerID, data: Data) {
        guard multipeerManager.managedPeers[peerID] != nil else {
            return
        }

        interactionManager.peerTokenReceived(for: peerID, data: data)

        // if own token already shared
        if interactionManager.managedPeers[peerID]?.tokenShared == true {
            interactionManager.runConfiguration(with: peerID)
        }
    }
}

@Observable
class InteractionManager: NSObject {
    var setError: ((ViewModel._Error) -> Void)?
    var setCoachingMessage: ((String) -> Void)?

    struct NIInfo {
        var niSession: NISession = NISession()
        var peerToken: NIDiscoveryToken?
        var tokenShared: Bool = false
        var updates: [NINearbyObject] = []
    }

    var managedPeers: [MCPeerID : NIInfo] = [:]

    override init() {
        super.init()
    }
}

extension InteractionManager {

    @MainActor func reset() {
        for niInfo in managedPeers.values {
            niInfo.niSession.invalidate()
        }
        managedPeers.removeAll()
    }


    @MainActor
    func initializePeer(_ peerID: MCPeerID) {
        self.managedPeers[peerID] = .init()
        self.managedPeers[peerID]!.niSession.delegate = self
    }

    @MainActor
    func shareDiscoveryToken(with peerID: MCPeerID, sendDateHandler: @escaping (Data, MCPeerID) -> Void) {
        if !checkAvailability() {
            return
        }

        if self.managedPeers[peerID] == nil {
            initializePeer(peerID)
        }

        if self.managedPeers[peerID]?.tokenShared == true {
            return
        }

        guard let sessionInfo = managedPeers[peerID], let token = sessionInfo.niSession.discoveryToken, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
            setError?(.tokenCreationFailed)
            return
        }

        print("sharing token with peer \(peerID.displayName)")

        sendDateHandler(data, peerID)
        self.managedPeers[peerID]?.tokenShared = true
    }


    @MainActor
    func peerTokenReceived(for peerID: MCPeerID, data: Data) {
        if self.managedPeers[peerID] == nil {
            initializePeer(peerID)
        }

        if let peerDiscoverToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NIDiscoveryToken.self, from: data) {
            self.managedPeers[peerID]?.peerToken = peerDiscoverToken
            return
        }
    }


    @MainActor
    func runConfiguration(with peerID: MCPeerID) {
        if !checkAvailability() {
            return
        }

        guard let sessionInfo = managedPeers[peerID], let peerToken = sessionInfo.peerToken else {
            return
        }
        let niSession = sessionInfo.niSession
        print("Start Interaction with \(peerID)")
        let config = NINearbyPeerConfiguration(peerToken: peerToken)
        if NISession.deviceCapabilities.supportsCameraAssistance {
            config.isCameraAssistanceEnabled = true
        }
        niSession.run(config)
    }



    @MainActor
    func invalidateInteraction(with peerID: MCPeerID) {
        guard let sessionInfo = managedPeers[peerID] else {
            return
        }
        sessionInfo.niSession.invalidate()
        initializePeer(peerID)
    }

    private func checkAvailability() -> Bool {
        if !NISession.deviceCapabilities.supportsPreciseDistanceMeasurement {
            setError?(.deviceUnsupported)
            return false
        }

        return true
    }
}

extension InteractionManager: NISessionDelegate {

    // functions for session that call session.starts
    func sessionDidStartRunning(_ session: NISession) {
        print("session started running")
    }

    // session suspend due to own device
    func sessionWasSuspended(_ session: NISession) {
        print("session was suspended")
    }

    func sessionSuspensionEnded(_ session: NISession) {
        // Session suspension ended. The session can now be run again.
        print("session suspension ended")
        if let config = session.configuration {
            session.run(config)
        }
    }

    // error code: https://developer.apple.com/documentation/nearbyinteraction/nierror/code
    func session(_ session: NISession, didInvalidateWith error: any Error) {
        print("session didInvalidateWith error: \(error)")
        self.setError?(.niSessionEnded(error.localizedDescription))
        DispatchQueue.main.async {
            if let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key {
                self.invalidateInteraction(with: peerID)
            }
        }
    }


    // functions for session that generates and send the discoveryToken to the peer device
    func session(_ session: NISession, didUpdate nearbyObjects: [NINearbyObject]) {
        if let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key {
            let objects = nearbyObjects.filter({$0.discoveryToken == self.managedPeers[peerID]?.peerToken})
            DispatchQueue.main.async {
                self.managedPeers[peerID]?.updates.insert(contentsOf: objects, at: 0)
            }
        }
    }

    func session(_ session: NISession, didUpdateAlgorithmConvergence convergence: NIAlgorithmConvergence, for object: NINearbyObject?) {

        if case .notConverged(let reasons) = convergence.status {
            guard !reasons.isEmpty else { return }
            var message = "Please perform the following for better measurements."
            for reason in reasons {

                switch reason {

                case .insufficientSignalStrength:
                    // Indicate to the user that the devices might be too far apart
                    message += "\n- move the device closer. "
                    break
                case .insufficientHorizontalSweep:
                    // Tell user to sweep device horizontally from side to side
                    message += "\n- sweep device horizontally from side to side. "
                    break
                case .insufficientVerticalSweep:
                    // Tell user to sweep device vertically up and down
                    message += "\n- sweep device vertically up and down. "
                    break
                case .insufficientMovement:
                    // Tell user to move around
                    message += "\n- move the device around. "

                    break
                case .insufficientLighting:
                    // Tell user to turn on the light
                    message += "\n- increase the environmental lighting. "
                    break
                default:
                    break
                }
            }

            setCoachingMessage?(message)
        }
    }


    // Session suspend/end due to peer device
    func session(_ session: NISession, didRemove nearbyObjects: [NINearbyObject], reason: NINearbyObject.RemovalReason) {
        print("session \(session) didRemove nearbyObjects: \(nearbyObjects), reason: \(reason)")
        guard let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key else {
            return
        }
        DispatchQueue.main.async {
            self.setError?(.niSessionEnded("Peer removed. Please try to resend the request."))
            self.invalidateInteraction(with: peerID)

        }
    }

}

@Observable
class MultipeerManager: NSObject {

    var setError: ((ViewModel._Error) -> Void)?
    var peerConnectedHandler: ((MCPeerID) -> Void)?
    var peerDisconnectedHandler: ((MCPeerID) -> Void)?
    var peerDataHandler: ((MCPeerID, Data) -> Void)?


    var isAdvertising: Bool = false {
        didSet {
            isAdvertising ? startAdvertising() : stopAdvertising()
        }
    }

    var isBrowsing: Bool = false {
        didSet {
            isBrowsing ? startBrowsing() : stopBrowsing()
        }
    }

    // peers that are not connected and are available to invite
    var peersAvailableToInvite: [MCPeerID : [String : String]?] {
        discoveredPeers.filter({ discoveredPeer in
            !managedPeers.contains(where: { managedPeer in
                return discoveredPeer.key == managedPeer.key && managedPeer.value == .connected
            })
        })
    }
    private var discoveredPeers: [MCPeerID : [String : String]?] = [:]

    // invitations from other devices
    var invitationsReceived: [MCPeerID : (Data?, (Bool, MCSession?) -> Void)] = [:]

    // peers managed by the MCSession
    var managedPeers: [MCPeerID : MCSessionState?] = [:]

    private let serviceType = "ni-service" // same as that in info.plist

    private static let peerIdKey = "peerIdKey"
    private var peerIDData: Data? = UserDefaults.standard.data(forKey: MultipeerManager.peerIdKey) {
        didSet {
            UserDefaults.standard.set(peerIDData, forKey: MultipeerManager.peerIdKey)
        }
    }

    private var session: MCSession?
    private var advertiser: MCNearbyServiceAdvertiser?
    private var browser: MCNearbyServiceBrowser?

    override init() {
        super.init()

        let peerID: MCPeerID = getMyPeerID()

        // session
        initializeSession(myPeerID: peerID)

        // advertiser
        advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: nil, serviceType: serviceType)
        advertiser?.delegate = self

        // browser
        browser = MCNearbyServiceBrowser(peer: peerID, serviceType: serviceType)
        browser?.delegate = self
    }

}

extension MultipeerManager {

    func reset() {
        self.isBrowsing = false
        self.isAdvertising = false
        self.disconnectSession()
        self.discoveredPeers.removeAll()
        self.invitationsReceived.removeAll()
        self.initializeSession(myPeerID: getMyPeerID())

        Array(self.managedPeers.keys).forEach({peerID in
            self.managedPeers[peerID] = .notConnected
        })
    }

    // peer id
    private func getMyPeerID() -> MCPeerID {
        let peerID: MCPeerID
        if let peerIDData, let _peerID = try? NSKeyedUnarchiver.unarchivedObject(ofClass: MCPeerID.self, from: peerIDData) {
            peerID = _peerID
        } else {
            peerID = MCPeerID(displayName: UIDevice.current.name)
            peerIDData = try? NSKeyedArchiver.archivedData(withRootObject: peerID, requiringSecureCoding: true)
        }
        return peerID
    }


    // session
    private func disconnectSession() {
        session?.disconnect()
    }

    private func initializeSession(myPeerID: MCPeerID) {
        let session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required)
        session.delegate = self
        self.session = session
    }


    func send(_ data: Data, to peerID: MCPeerID) {
        guard let session else {
            setError?(.sendMessageFailed("Session not available."))
            return
        }
        do {
            try session.send(data, toPeers: [peerID], with: .reliable)
        } catch (let error) {
            setError?(.sendMessageFailed(error.localizedDescription))
        }
    }


    // Advertisement
    private func startAdvertising() {
        advertiser?.startAdvertisingPeer()
    }

    private func stopAdvertising() {
        advertiser?.stopAdvertisingPeer()
    }

    func handleInvitation(_ peerID: MCPeerID, accept: Bool) {
        guard let info = invitationsReceived[peerID] else {
            return
        }
        info.1(accept, session)
        DispatchQueue.main.async {
            self.managedPeers[peerID] = nil as MCSessionState?
            self.invitationsReceived.removeValue(forKey: peerID)
        }
    }

    // Browse
    private func startBrowsing() {
        browser?.startBrowsingForPeers()
    }

    private func stopBrowsing() {
        browser?.stopBrowsingForPeers()
    }

    func invite(_ peerID: MCPeerID, context: Data? = nil, timeout: TimeInterval /*sec*/) {
        guard let session else {
            setError?(.invitationFailed("Session not available."))
            return
        }
        browser?.invitePeer(peerID, to: session, withContext: context, timeout: timeout)
        DispatchQueue.main.async {
            self.managedPeers[peerID] = nil as MCSessionState?
        }
    }
}

extension MultipeerManager: MCNearbyServiceBrowserDelegate {
    func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
        print("found peer: \(peerID.displayName) with info \(String(describing: info))")
        DispatchQueue.main.async {
            self.discoveredPeers[peerID] = info
        }
    }

    func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
        print("lost peer: \(peerID.displayName)")
        DispatchQueue.main.async {
            self.discoveredPeers.removeValue(forKey: peerID)
        }
    }

    func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: any Error) {
        self.setError?(.startBrowsingFailed("Failed to start browsing for peers with error: \(error.localizedDescription)"))
    }
}

extension MultipeerManager: MCNearbyServiceAdvertiserDelegate {
    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        print("invitation received from \(peerID.displayName), with context: \(String(describing: context?.string))")
        DispatchQueue.main.async {
            self.invitationsReceived[peerID] = (context, invitationHandler)
        }
    }

    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: any Error) {
        self.setError?(.startAdvertisingFailed("Failed to start advertising with error: \(error.localizedDescription)"))
    }
}

extension MultipeerManager: MCSessionDelegate {
    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        print("peer state changed for \(peerID.displayName): \(state.displayString)")
        if state == .connected {
            peerConnectedHandler?(peerID)
        }
        if state == .notConnected {
            peerDisconnectedHandler?(peerID)
        }
        self.managedPeers[peerID] = state
    }

    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        peerDataHandler?(peerID, data)
    }

    func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        print("receive stream.")
    }

    func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
        print("start receiving resource with progress: \(progress)")
    }

    func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) {
        print("finish receiving resource. url: \(String(describing: localURL)), error: \(String(describing: error))")
    }

}

The MultipeerManager, our helper for advertising our own device, browsing for other peers and making connections using the Multipeer Connectivity stays pretty much (almost exactly) the same as what I have shared with you in ***SwiftUI: Peer-to-Peer (P2P) with Multipeer Connectivity Framework .***

The only thing to keep in mind here are the following three handlers where we will be using as triggers to initialize, start and stop nearby interactions.

var peerConnectedHandler: ((MCPeerID) -> Void)?
var peerDisconnectedHandler: ((MCPeerID) -> Void)?
var peerDataHandler: ((MCPeerID, Data) -> Void)?

I will skip the rest of my explanations and focusing on the important parts! Our Nearby Interaction framework!

So! Again! If you need a catch up, ***SwiftUI: Peer-to-Peer (P2P) with Multipeer Connectivity Framework*** is there for you!

Locate and Connect to Peers

We are pretty much doing nothing (nearby interaction wise, multipeer connectivity is doing some hard work) until our peerConnectedHandler is called, telling our ViewModel that a peer has connected to our device.

We can then initializePeer by creating an [NISession](https://developer.apple.com/documentation/nearbyinteraction/nisession) and assign [NISessionDelegate](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate).

// InteractionManager
func initializePeer(_ peerID: MCPeerID) {
    self.managedPeers[peerID] = .init()
    self.managedPeers[peerID]!.niSession.delegate = self
}

That’s it!

I have decided that I will not try to automatically start an interaction section upon connection and let my user decide whether if they want to interact.

But if you were to, all you have to do is to add interactionManager.shareDiscoveryToken, the exchange token function that we will be taking a more detailed look next, after assigning the delegate. And move the interactionManager.runConfiguration outside of the if clause within the dataReceivedHandler.

IMPORTANT NOTE!

One session represents an interaction between the user and a SINGLE nearby object.

By that means, we will need a separate session for each nearby objects (I meant peers…).

If you want to find out more about

Check Availability

Before we can do anything with the Nearby Interaction framework, we will first need to make sure that the device our app running on actually supports it by checking the [deviceCapabilities](https://developer.apple.com/documentation/nearbyinteraction/nisession/devicecapabilities) property

// InteractionManager 
private func checkAvailability() -> Bool {
    if !NISession.deviceCapabilities.supportsPreciseDistanceMeasurement {
        setError?(.deviceUnsupported)
        return false
    }

    return true
}

If you are planning on supporting iOS 15 or earlier, you can check the session’s [isSupported](https://developer.apple.com/documentation/nearbyinteraction/nisession/issupported) flag.

Exchange Token

[discoveryToken](https://developer.apple.com/documentation/nearbyinteraction/nisession/discoverytoken) is a property set by the framework automatically when an [NISession](https://developer.apple.com/documentation/nearbyinteraction/nisession) is created.This value is unique to the session and identifies the device that created the session.

To begin a session, we will first need to share it with the peer that we have connected to above.

// InteractionManager
@MainActor
func shareDiscoveryToken(with peerID: MCPeerID, sendDateHandler: @escaping (Data, MCPeerID) -> Void) {
    if !checkAvailability() {
        return
    }

    if self.managedPeers[peerID] == nil {
        initializePeer(peerID)
    }

    if self.managedPeers[peerID]?.tokenShared == true {
        return
    }

    guard let sessionInfo = managedPeers[peerID], let token = sessionInfo.niSession.discoveryToken, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
        setError?(.tokenCreationFailed)
        return
    }
    sendDateHandler(data, peerID)

    self.managedPeers[peerID]?.tokenShared = true
}

We are using [NSKeyedArchiver.archivedData](https://developer.apple.com/documentation/foundation/nskeyedarchiver/2962880-archiveddata) to convert the [discoveryToken](https://developer.apple.com/documentation/nearbyinteraction/nisession/discoverytoken) to Data so that we can share it through Multipeer connectivity framework.

Create Configuration and Run

Upon receiving the data from our peers, we can parse the data to [discoveryToken](https://developer.apple.com/documentation/nearbyinteraction/nisession/discoverytoken), if possible.

// InteractionManager
@MainActor
func peerTokenReceived(for peerID: MCPeerID, data: Data) {
    if self.managedPeers[peerID] == nil {
        initializePeer(peerID)
    }

    if let peerDiscoverToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NIDiscoveryToken.self, from: data) {
        self.managedPeers[peerID]?.peerToken = peerDiscoverToken
        return
    }
}

And call runConfiguration to create an [NINearbyPeerConfiguration](https://developer.apple.com/documentation/nearbyinteraction/ninearbypeerconfiguration) from the [discoveryToken](https://developer.apple.com/documentation/nearbyinteraction/nisession/discoverytoken) and [run](https://developer.apple.com/documentation/nearbyinteraction/nisession/run(_:)) the configuration to start an interaction session.

// InteractionManager
@MainActor
func runConfiguration(with peerID: MCPeerID) {
    if !checkAvailability() {
        return
    }

    guard let sessionInfo = managedPeers[peerID], let peerToken = sessionInfo.peerToken else {
        return
    }
    let niSession = sessionInfo.niSession
    let config = NINearbyPeerConfiguration(peerToken: peerToken)
    if NISession.deviceCapabilities.supportsCameraAssistance {
        config.isCameraAssistanceEnabled = true
    }
    niSession.run(config)
}

Little Discussion: Send Only vs Exchange

Before we move onto the delegation methods, let’s take a look at where we are calling the shareDiscoveryToken and runConfiguration really quick for a short discussion on whether we need two-way exchange or not.

For shareDiscoveryToken, two places. ViewModel.sendInteractionRequest and ViewModel.acceptInteractionRequest, where the first one is to send a peer saying Hey, I want to interact with you, and the second one being Hey, I got your request and I do want to interact!

runConfiguration is also called twice. One being the ViewModel.acceptInteractionRequest above, and another one being ViewModel.dataReceivedHandler where if we have already shared our token, ie: we are the requesting side, we will run the configuration using the token we received.

It might be more clear after checking out a simple View example that I will be sharing with you shortly, but keep it somewhere in mind.

What was I trying to say here?

Each method is called twice, once from each side!

Why I am doing this?

Because I want both devices to see the measurements!

Only the device that call runConfiguration with the peer token received will have [session(_:didUpdate:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didupdate:)) triggered with measurements data.

That is if we want both devices to be able to check out the measurements, we will have to exchange two ways, sending our token to the peer and receiving the token from the peer!

NISessionDelegate

Let’s start with the [session(_:didUpdate:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didupdate:)) method that we will be receiving our measurement data. All we will be doing here is adding the new [NINearbyObject](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject)s to our array.

func session(_ session: NISession, didUpdate nearbyObjects: [NINearbyObject]) {
    if let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key {
        let objects = nearbyObjects.filter({$0.discoveryToken == self.managedPeers[peerID]?.peerToken})
        DispatchQueue.main.async {
            self.managedPeers[peerID]?.updates.insert(contentsOf: objects, at: 0)
        }
    }
}

If we are interacting with third-party accessories, the [session(_:didGenerateShareableConfigurationData:for:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didgenerateshareableconfigurationdata:for:)) method will be invoked instead, passing in configuration data that the app sends to the accessory.

In the case where we have isCameraAssistanceEnabled setting to true, we will also have [session(_:didUpdateAlgorithmConvergence:for:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didupdatealgorithmconvergence:for:)) providing recommended actions the user can take to facilitate the framework’s Camera Assistance.

func session(_ session: NISession, didUpdateAlgorithmConvergence convergence: NIAlgorithmConvergence, for object: NINearbyObject?) {

    if case .notConverged(let reasons) = convergence.status {
        guard !reasons.isEmpty else { return }
        var message = "Please perform the following for better measurements."
        for reason in reasons {

            switch reason {

            case .insufficientSignalStrength:
                // Indicate to the user that the devices might be too far apart
                message += "\n- move the device closer. "
                break
            case .insufficientHorizontalSweep:
                // Tell user to sweep device horizontally from side to side
                message += "\n- sweep device horizontally from side to side. "
                break
            case .insufficientVerticalSweep:
                // Tell user to sweep device vertically up and down
                message += "\n- sweep device vertically up and down. "
                break
            case .insufficientMovement:
                // Tell user to move around
                message += "\n- move the device around. "

                break
            case .insufficientLighting:
                // Tell user to turn on the light
                message += "\n- increase the environmental lighting. "
                break
            default:
                break
            }
        }

        setCoachingMessage?(message)
    }
}

In addition, we also have couple other methods that help us managing our own session state and that of our peer.

To manage our own session state, we first have [session(_:didInvalidateWith:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didinvalidatewith:)). This will tell us that a specific NISession is invalidate by the system with the [error](https://developer.apple.com/documentation/nearbyinteraction/nierror/code) describing the reason.

We then have

// functions for session that call session.starts
func sessionDidStartRunning(_ session: NISession) {
    print("session started running")
}

// session suspend due to own device
func sessionWasSuspended(_ session: NISession) {
    print("session was suspended")
}

func sessionSuspensionEnded(_ session: NISession) {
    // Session suspension ended. The session can now be run again.
    print("session suspension ended")
    if let config = session.configuration {
        session.run(config)
    }
}

func session(_ session: NISession, didInvalidateWith error: any Error) {
    print("session didInvalidateWith error: \(error)")
    self.setError?(.niSessionEnded(error.localizedDescription))
    DispatchQueue.main.async {
        if let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key {
            self.invalidateInteraction(with: peerID)
        }
    }
}

If the app stays backgrounded for too long during a suspension, the session will be invalidated and [session(_:didInvalidateWith:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didinvalidatewith:)) will be invoked with [NIError.Code.resourceUsageTimeout](https://developer.apple.com/documentation/nearbyinteraction/nierror/code/resourceusagetimeout).

To manage our peer state, we have [session(_:didRemove:reason:)](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didremove:reason:)).

This will be invoked in our peer’s device, for example, in the above scenario where our app stays backgrounded for too long with the reason being [NINearbyObject.RemovalReason.timeout](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/removalreason/timeout). If we have called [invalidate()](https://developer.apple.com/documentation/nearbyinteraction/nisession/invalidate()) in our device, then our peer will receive the [didRemove](https://developer.apple.com/documentation/nearbyinteraction/nisessiondelegate/session(_:didremove:reason:)) call with [NINearbyObject.RemovalReason.peerEnded](https://developer.apple.com/documentation/nearbyinteraction/ninearbyobject/removalreason/peerended).

func session(_ session: NISession, didRemove nearbyObjects: [NINearbyObject], reason: NINearbyObject.RemovalReason) {
    print("session \(session) didRemove nearbyObjects: \(nearbyObjects), reason: \(reason)")
    guard let peerID = self.managedPeers.first(where: {$0.value.niSession == session})?.key else {
        return
    }
    DispatchQueue.main.async {
        self.setError?(.niSessionEnded("Peer removed. Please try to resend the request."))
        self.invalidateInteraction(with: peerID)
    }
}

I am handling both cases the same way but you could also check the reason and handle accordingly, for example, try to reconnect if it is time out by calling [run(_:)](https://developer.apple.com/documentation/nearbyinteraction/nisession/run(_:)) again.

Sample Views

That’s all we need to know about Nearby Interaction to start interacting with our peers and receive measurements!

Let’s create couple sample views just to check out when exactly those functions are called!


import SwiftUI
import MultipeerConnectivity
import NearbyInteraction

struct ContentView: View {
    @State private var viewModel = ViewModel()
    @Environment(\.scenePhase) private var scenePhase

    @State private var showInvitationSheet = false
    @State private var selectedPeerID: MCPeerID? = nil
    @State private var invitationMessage: String = ""
    @State private var invitationTimeout: Int = 30

    var body: some View {
        @Bindable var viewModel = viewModel

        List {

            if let error = viewModel.error {
                Text(error.message)
                    .multilineTextAlignment(.leading)
                    .foregroundStyle(.red)
            }


            Section {

                if viewModel.multipeerManager.peersAvailableToInvite.isEmpty {
                    Text("No peer available to invite.")
                        .foregroundStyle(.secondary)
                }
                ForEach(Array(viewModel.multipeerManager.peersAvailableToInvite.keys), id: \.self) { key in
                    let peerID: MCPeerID = key
                    let discoveryInfo = viewModel.multipeerManager.peersAvailableToInvite[key] ?? [:]

                    HStack(alignment: .top) {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(peerID.displayName)
                                .lineLimit(1)
                                .truncationMode(.tail)
                                .minimumScaleFactor(0.8)

                            if let discoveryInfo = discoveryInfo, discoveryInfo.isEmpty == false {
                                VStack(alignment: .leading, spacing: 0) {
                                    Text("additional Info:")
                                    ForEach(Array(discoveryInfo.keys), id: \.self) { discoveryInfoKey in
                                        if let info = discoveryInfo[discoveryInfoKey] {
                                            Text("\(discoveryInfoKey): \(info)")
                                        }
                                    }
                                }
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                            } else {
                                Text("No additional Info available.")
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                            }
                        }
                        Spacer()

                        Button(action: {
                            selectedPeerID = peerID
                            showInvitationSheet = true
                        }, label: {
                            Text("Invite")
                        })
                        .buttonStyle(.borderless)
                        .font(.headline)

                    }
                }

            } header: {
                HStack {
                    Text("Browse")
                    Toggle("", isOn: $viewModel.multipeerManager.isBrowsing)
                        .scaleEffect(0.7, anchor: .trailing)
                }
            }

            Section {
                if viewModel.multipeerManager.invitationsReceived.isEmpty {
                    Text("No invitation received.")
                        .foregroundStyle(.secondary)
                }

                ForEach(Array(viewModel.multipeerManager.invitationsReceived.keys), id: \.self) { key in
                    let peerID: MCPeerID = key
                    let context: Data? = viewModel.multipeerManager.invitationsReceived[key]?.0

                    HStack(alignment: .center) {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(peerID.displayName)
                                .lineLimit(1)
                                .truncationMode(.tail)
                                .minimumScaleFactor(0.8)
                            let message = if let s = context?.string, !s.isEmpty {
                                s
                            } else {
                                "(no message)"
                            }
                            Text("Message: \(message)")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }
                        .frame(maxHeight: .infinity)

                        Spacer()

                        VStack(alignment: .trailing, spacing: 4) {
                            Button(action: {
                                viewModel.multipeerManager.handleInvitation(peerID, accept: true)
                            }, label: {
                                Text("Accept")
                            })
                            Button(action: {
                                viewModel.multipeerManager.handleInvitation(peerID, accept: false)
                            }, label: {
                                Text("Decline")
                            })
                        }
                        .buttonStyle(.borderless)
                        .font(.subheadline)
                        .frame(maxHeight: .infinity)

                    }
                    .fixedSize(horizontal: false, vertical: true)

                }


            } header: {
                VStack(alignment: .leading) {
                    HStack {
                        Text("Advertise")
                        Toggle("", isOn: $viewModel.multipeerManager.isAdvertising)
                            .scaleEffect(0.7, anchor: .trailing)
                    }
                    Text("Invitation Received")
                }
            }


            Section {
                if viewModel.multipeerManager.managedPeers.isEmpty {
                    VStack(alignment: .leading) {
                        Text("No peers added.")
                        Text("Send or accept an invitation to get started.")
                            .font(.subheadline)
                    }
                    .foregroundStyle(.secondary)

                }


                ForEach(Array(viewModel.multipeerManager.managedPeers.keys), id: \.self) { key in
                    let peerID: MCPeerID = key
                    let state: MCSessionState? = viewModel.multipeerManager.managedPeers[key] ?? nil

                    NavigationLink(destination: {
                        InteractionView(peerID: peerID)
                            .environment(viewModel)
                    }, label: {
                        HStack {
                            Text(peerID.displayName)
                                .lineLimit(1)
                                .truncationMode(.tail)
                                .minimumScaleFactor(0.8)

                            Spacer()
                            if let state {
                                Text(state.displayString)
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                            }
                        }

                    })
                }

            } header: {
                HStack {
                    Text("Current Session")
                }
            }


        }
        .buttonStyle(.plain)
        .navigationTitle("Let's Interact!")
        .navigationBarTitleDisplayMode(.inline)
        .onChange(of: scenePhase, {
            print("scene phase changed")
            if scenePhase == .background || scenePhase == .inactive {
                self.viewModel.multipeerManager.isBrowsing = false
                self.viewModel.multipeerManager.isAdvertising = false
            }
        })
        .toolbar(content: {
            Button(action: {
                viewModel.reset()
            }, label: {
                Text("Reset")
            })
        })
        .sheet(isPresented: $showInvitationSheet, content: {
            VStack(spacing: 24) {
                HStack {
                    Text("Invitation Details")
                        .font(.headline)
                        .lineLimit(1)

                    HStack(spacing: 16) {

                        Button(action: {
                            showInvitationSheet = false
                        }, label: {
                            Text("Cancel")
                                .foregroundStyle(.red)
                        })
                        .buttonStyle(.bordered)

                        Button(action: {
                            guard let peerID = selectedPeerID else {
                                return
                            }
                            let timeout = TimeInterval(invitationTimeout)
                            viewModel.multipeerManager.invite(peerID, context: invitationMessage.data, timeout: timeout)
                            showInvitationSheet = false
                        }, label: {
                            Text("Send")
                        })
                        .buttonStyle(.bordered)
                    }
                    .frame(maxWidth: .infinity, alignment: .trailing)

                }

                VStack(alignment: .leading) {
                    Text("Messages")

                    TextField("", text: $invitationMessage, axis: .vertical)
                        .lineLimit(2, reservesSpace: true)
                }

                VStack(alignment: .leading) {
                    HStack {
                        Text("Timeout (sec)")
                        Spacer()
                        TextField("", value: $invitationTimeout, format: .number)
                            .keyboardType(.numberPad)
                            .frame(width: 48)

                    }
                    Text("If a negative value or zero is specified, the default timeout (30 seconds) is used.")
                        .multilineTextAlignment(.leading)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }

            }
            .textFieldStyle(.roundedBorder)
            .padding(.all, 32)
            .frame(maxHeight: .infinity, alignment: .top)
            .presentationDetents(.init([.height(360)]))
            .onAppear {
                invitationMessage = ""
            }
            .onChange(of: showInvitationSheet, initial: true, {
                if !showInvitationSheet {
                    selectedPeerID = nil
                }
            })
        })

    }
}

import simd
struct InteractionView: View {
    @Environment(ViewModel.self) private var viewModel
    var peerID: MCPeerID

    @State private var showPastUpdates: Bool = false

    var body: some View {
        let state: MCSessionState? = viewModel.multipeerManager.managedPeers[peerID] ?? nil
        let niInfo: InteractionManager.NIInfo? = viewModel.interactionManager.managedPeers[peerID]

        List {
            if let error = viewModel.error {
                Text(error.message)
                        .multilineTextAlignment(.leading)
                        .foregroundStyle(.red)
            }

            if let niInfo, state == .connected {

                if !niInfo.tokenShared && niInfo.peerToken == nil {
                    Button(action: {
                        viewModel.sendInteractionRequest(to: peerID)
                    }, label: {
                        Text("Send interaction request")
                    })
                    .buttonStyle(.borderless)
                }

                if niInfo.peerToken != nil && !niInfo.tokenShared {
                    HStack {
                        Text("Interaction request received")

                        Spacer()

                        Button(action: {
                            viewModel.acceptInteractionRequest(from: peerID)
                        }, label: {
                            Text("Accept")
                        })
                        .buttonStyle(.borderless)

                    }
                }


                if let latestUpdate = niInfo.updates.first {
                    let distance = latestUpdate.distance
                    let direction = latestUpdate.direction
                    let azimuth = latestUpdate.azimuth
                    let elevation = latestUpdate.elevation

                    VStack(spacing: 16) {
                        if let message = viewModel.coachingMessage {
                            Text(message)
                            .font(.headline)
                            .multilineTextAlignment(.leading)
                        }

                        if distance == nil && direction == nil {
                            VStack {
                                Text("Measurements unavailable")
                                    .font(.title3)
                                    .fontWeight(.bold)
                                    .minimumScaleFactor(0.5)
                                    .lineLimit(1)
                                Text("Peer device out of range")
                                Text("Please move closer")

                            }
                        }

                        if let distance {
                            Text("\(distance.displayString) m")
                                .font(.title2)
                                .fontWeight(.bold)

                            if let azimuth, let elevation {

                                HStack(spacing: 48) {
                                    HStack {
                                        Image(systemName: "arrow.left")
                                            .opacity(azimuth < 0 ? 1 : 0.3)
                                        Text("\(azimuth.radiansToDegrees.displayString)°")
                                        Image(systemName: "arrow.right")
                                            .opacity(azimuth > 0 ? 1 : 0.3)
                                    }

                                    HStack {
                                        Text("\(elevation.radiansToDegrees.displayString)°")
                                        Image(systemName: elevation > 0 ? "arrow.up" : "arrow.down")

                                    }
                                }
                            } else {
                                VStack {
                                    Text("Direction unavailable")
                                        .font(.title3)
                                        .fontWeight(.bold)
                                        .minimumScaleFactor(0.5)
                                        .lineLimit(1)
                                    Text("Peer device out of line of sight")
                                    Text("Please point to each other")
                                }
                            }
                        }

                    }
                    .frame(maxWidth: .infinity)
                    .frame(minHeight: 180)
                } else {
                    Text("No measurements yet.")
                }


                // past updates
                let pastUpdates: [NINearbyObject] = niInfo.updates
                if pastUpdates.count > 1 {

                    VStack(alignment: .leading, spacing: 16) {

                        Button(action: {
                            showPastUpdates.toggle()
                        }, label: {
                            HStack {
                                Text("Past measurements")
                                    .fontWeight(.bold)
                                Spacer()
                                Image(systemName: showPastUpdates ? "chevron.down" : "chevron.left")
                            }
                        })
                        .frame(maxWidth: .infinity, alignment: .leading)


                        if showPastUpdates {
                            ForEach(0..<pastUpdates.count, id:\.self) { index in
                                let update = pastUpdates[index]

                                let distance = if let s = update.distance?.displayString {
                                    "\(s) m"
                                } else {
                                    "(not available)"
                                }
                                let azimuth = if let s = update.azimuth?.radiansToDegrees.displayString {
                                    "\(s)°"
                                } else {
                                    "(not available)"
                                }
                                let elevation = if let s = update.elevation?.radiansToDegrees.displayString {
                                    "\(s)°"
                                } else {
                                    "(not available)"
                                }

                                Text("- **distance**: \(distance) m; **azimuth**: \(azimuth); **elevation**: \(elevation)")
                            }

                        }
                    }
                    .font(.subheadline)
                    .foregroundStyle(.gray)
                }

            } else {
                VStack(alignment: .leading) {
                    Text("Peer not connected.")
                    Text("Please go back to invite!")
                }
            }

        }
        .listRowSpacing(16)
        .listRowSeparator(.hidden)
        .navigationTitle("\(peerID.displayName)")
        .navigationBarTitleDisplayMode(.inline)
        .buttonStyle(.plain)
        .scrollDisabled(!showPastUpdates)
        .onDisappear {
            viewModel.interactionManager.invalidateInteraction(with: peerID)
        }
    }
}

extension Float {
    var radiansToDegrees: Self {
        self * 180 / .pi
    }

    var displayString: String {
        String(format: "%.2f", self)
    }
}

extension MCSessionState {
    var displayString: String {
        switch self {
        case .notConnected:
            return "Not Connected"
        case .connecting:
            return "Connecting..."
        case .connected:
            return "Connected"
        @unknown default:
            return "Unknown"
        }
    }
}

Thank you for reading!

Again, feel free to grab the demo project from my ***GitHub*!**

Happy Interacting!


메타데이터
post_id
4b799e0dbf39
slug
swiftui-locate-peers-with-nearby-interaction-framework-4b799e0dbf39
url
https://levelup.gitconnected.com/swiftui-locate-peers-with-nearby-interaction-framework-4b799e0dbf39
canonical_url
https://levelup.gitconnected.com/swiftui-locate-peers-with-nearby-interaction-framework-4b799e0dbf39
author_url
https://medium.com/@itsuki.enjoy
status
ok
fetched_at
2026-09-05 13:46:36