Location Sharing

Introduction

Location sharing is a powerful feature that allows users to share their current position or real-time location with other participants in a channel. Stream Chat supports both static and live location sharing through location attachments.

There are two types of location sharing:

  • Static Location: A one-time location share that does not update over time.
  • Live Location: A real-time location share that updates over time for a specified duration.

The SDK handles location message creation and updates, but location tracking must be implemented by the application using device location services.

Adding Location Sharing into a UIKit Chat App

In this guide, we will be adding location sharing functionality to a UIKit chat app, similar to WhatsApp, iMessage, or Telegram. Other location sharing use cases, like delivery tracking, can be implemented in a similar way. The Stream Chat SDK provides the underlying functionality for location sharing, but, at the moment, does not include default UI components. However, we provide a complete example implementation in our Demo App that you can reference and adapt for your needs.

You can find the reference implementation in the Custom Attachments folder, which includes the following features:

  • Location picker view
  • Location sharing permissions handling
  • Location snapshot preview for the message list
  • Full map view with live updates

After completing the guide, you should have a working location sharing functionality in your chat app, like in the pictures below:

Location PickerMessage ListMap View
Location Picker
Message List
Map View

Setting up location services

For location sharing functionality, it’s important that you ask for permissions to use the device location services. Therefore, you should add the following entries in your Info.plist file:

  • Privacy - Location When In Use Usage Description - “YOUR_APP_NAME requires location access to share your location with other users”
  • Privacy - Location Always and When In Use Usage Description - “YOUR_APP_NAME requires location access to share your location with other users”

Once you have added the entries, you will need to create a LocationProvider class to handle the location monitoring as well as the permission requests. If you already have a similar class in your app, you can use it, otherwise, you can take inspiration from our LocationProvider in the Demo App.

Monitoring location updates

In order to update the location of the user, you need to set up the CurrentChatUserController and implement location tracking. The CurrentChatUserController makes it easy to start and stop monitoring location updates, as well as observing the active live location messages. The controller will only call the didStartSharingLiveLocation() if the user was not already sharing a live location, and when initializing the controller if the user was already sharing a live location.

The following steps are required to set up the location tracking:

  1. Set up the CurrentChatUserController and set the delegate
  2. Load the initial active live location messages (only once)
  3. Send location updates by calling currentUserController.updateLiveLocation()
  4. Observe when to start and stop the location updates through the delegate methods

We recommend setting up the location tracking as soon as you connect the user. In the example below, taken from our Demo App, we set up the location tracking in the tab bar view controller of the app, but you can do it wherever is more convenient for you.

class DemoAppTabBarController: UITabBarController, CurrentChatUserControllerDelegate, MessageReminderListControllerDelegate {
    let locationProvider = LocationProvider.shared
    let currentUserController: CurrentChatUserController

    override func viewDidLoad() {
        super.viewDidLoad()

        // Set up the current user controller and load the initial active live location messages
        currentUserController.delegate = self
        currentUserController.loadActiveLiveLocationMessages()

        // Set up the location updates
        locationProvider.didUpdateLocation = { [weak self] location in
            let newLocation = LocationInfo(
                latitude: location.coordinate.latitude,
                longitude: location.coordinate.longitude
            )
            self?.currentUserController.updateLiveLocation(newLocation)
        }
    }

    func currentUserControllerDidStartSharingLiveLocation(
        _ controller: CurrentChatUserController
    ) {
        locationProvider.startMonitoringLocation()
    }

    func currentUserControllerDidStopSharingLiveLocation(_ controller: CurrentChatUserController) {
        locationProvider.stopMonitoringLocation()
    }

    func currentUserController(
        _ controller: CurrentChatUserController,
        didChangeActiveLiveLocationMessages messages: [ChatMessage]
    ) {
        // Whenever the active live location messages change
        // this delegate method will be called.
        // You can use this to update some custom UI or for fine-grained control over the location tracking.
    }
}

By default, the CurrentChatUserController does not handle if the location was created from the current device or not. However, every location object has a createdByDeviceId property that you can use to check if the location was created from the current device. If you want to make sure that location sharing is device-specific, you can use the didChangeActiveLiveLocationMessages delegate method to control when to track location updates, and ignore the didStartSharingLiveLocation() and didStopSharingLiveLocation() methods. Here is a simple example on how you can do this:

func currentUserController(
    _ controller: CurrentChatUserController,
    didChangeActiveLiveLocationMessages messages: [ChatMessage]
) {
    let currentDeviceId = controller.currentUser?.currentDevice?.id
    let currentDeviceLocationMessages = messages.filter {
        $0.sharedLocation?.createdByDeviceId == currentDeviceId
    }
    if currentDeviceLocationMessages.isEmpty {
        locationProvider.stopMonitoringLocation()
    } else {
        locationProvider.startMonitoringLocation()
    }
}

Adding location picker

For sending location messages, we first need to customize the composer.

For this, we’ll need to:

  1. Create a custom composer view controller that inherits from ComposerVC
  2. Add a location button to the composer’s attachment button collection
  3. Present a location picker view controller to select the location type (static or live)

In order to add a new location action button to the composer, we need to override the attachmentsPickerActions property of the ComposerVC class. When the button is tapped, we will present a location picker view controller to select the location type (static or live).

class DemoComposerVC: ComposerVC {
    override var attachmentsPickerActions: [UIAlertAction] {
        var actions = super.attachmentsPickerActions
        let isLocationEnabled = channelController?.channel?.config.sharedLocationsEnabled == true
        if isLocationEnabled && content.isInsideThread == false {
            let locationAction = UIAlertAction(
                title: "Location",
                style: .default,
                handler: { [weak self] _ in
                    self?.presentLocationSelection()
                }
            )
            actions.append(locationAction)
        }

        return actions
    }

    func presentLocationSelection() {
        guard let channelController = channelController else { return }
        let locationSelectionVC = LocationSelectionViewController(channelController: channelController)
        let navigationController = UINavigationController(rootViewController: locationSelectionVC)
        present(navigationController, animated: true)
    }
}

Components.default.messageComposerVC = DemoComposerVC.self

As you can see, we are checking if the location sharing is enabled for the channel and if the message is not inside a thread. Since location sharing is not supported inside threads.

Then, we are creating a new LocationSelectionViewController instance and presenting it. This view controller will be responsible for requesting the location permission and selecting whether the user wants to send a static or live location message.

class LocationSelectionViewController: UIViewController, ThemeProvider {
    private let channelController: ChatChannelController
    private let locationProvider = LocationProvider.shared
    private var currentLocation: CLLocation?

    init(channelController: ChatChannelController) {
        self.channelController = channelController
        super.init(nibName: nil, bundle: nil)
    }

    private lazy var mapView: MKMapView = {
        let view = MKMapView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.isZoomEnabled = true
        view.showsCompass = false
        view.showsUserLocation = true
        return view
    }()

    private lazy var staticLocationButton: LocationOptionButton = {
        let button = LocationOptionButton()
        button.configure(
            icon: UIImage(systemName: "mappin.circle"),
            title: "Send Current Location",
            subtitle: "Share your current location once"
        )
        button.addTarget(self, action: #selector(sendStaticLocationTapped), for: .touchUpInside)
        button.isEnabled = false
        return button
    }()

    private lazy var liveLocationButton: LocationOptionButton = {
        let button = LocationOptionButton()
        button.configure(
            icon: UIImage(systemName: "location.circle"),
            title: "Share Live Location",
            subtitle: "Share your location in real-time"
        )
        button.addTarget(self, action: #selector(shareLiveLocationTapped), for: .touchUpInside)
        button.isEnabled = false
        button.layer.borderColor = appearance.colorPalette.accentPrimary.cgColor
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupView()
        setupConstraints()
        getCurrentLocation()
    }

    private func getCurrentLocation() {
        locationProvider.getCurrentLocation { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let location):
                    self?.handleLocationReceived(location)
                case .failure:
                    self?.showLocationPermissionAlert()
                }
            }
        }
    }

    private func handleLocationReceived(_ location: CLLocation) {
        currentLocation = location

        let coordinate = location.coordinate
        let region = MKCoordinateRegion(
            center: coordinate,
            latitudinalMeters: 1000,
            longitudinalMeters: 1000
        )
        mapView.setRegion(region, animated: true)
        mapView.userTrackingMode = .follow

        staticLocationButton.isEnabled = true
        liveLocationButton.isEnabled = true
    }

    private func showLocationPermissionAlert() {
        let alert = UIAlertController(
            title: "Location Access Required",
            message: "Please enable location access in Settings to share your location.",
            preferredStyle: .alert
        )

        alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
            if let url = URL(string: UIApplication.openSettingsURLString) {
                UIApplication.shared.open(url)
            }
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

        present(alert, animated: true)
    }

    @objc private func sendStaticLocationTapped() {
        guard let location = currentLocation else { return }

        let locationInfo = LocationInfo(
            latitude: location.coordinate.latitude,
            longitude: location.coordinate.longitude
        )

        channelController.sendStaticLocation(locationInfo)
        dismiss(animated: true)
    }

    @objc private func shareLiveLocationTapped() {
        guard let location = currentLocation else { return }

        let alertController = UIAlertController(
            title: "Share Live Location",
            message: "Select the duration for sharing your live location.",
            preferredStyle: .actionSheet
        )

        let durations: [(String, TimeInterval)] = [
            ("1 minute", 61),
            ("10 minutes", 600),
            ("1 hour", 3600)
        ]

        for (title, duration) in durations {
            let action = UIAlertAction(title: title, style: .default) { [weak self] _ in
                guard let self = self, let location = self.currentLocation else { return }
                let locationInfo = LocationInfo(
                    latitude: location.coordinate.latitude,
                    longitude: location.coordinate.longitude
                )
                let endDate = Date().addingTimeInterval(duration)
                self.channelController.startLiveLocationSharing(locationInfo, endDate: endDate) { [weak self] result in
                    self?.dismiss(animated: true)
                }
            }
            alertController.addAction(action)
        }

        alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alertController, animated: true)
    }
}

We use the LocationProvider class to get the current location of the user, which will first ask for permissions if the user has not granted them yet. Then, we are setting up the map view so that the user can see their current location and the two buttons to select the location type. If the user taps the static location button, we will send a static location message to the channel by calling channelController.sendStaticLocation(locationInfo). If the user taps the live location button, we will present an alert controller to select the duration for the live location sharing, and call channelController.startLiveLocationSharing(locationInfo, endDate: endDate).

For the full implementation, you can check the LocationSelectionViewController in the Demo App.

Adding message location view

Now that we have a way to send location messages, we need to render them in the message list. The Stream Chat SDK uses an attachment system to customize how different types of content are displayed. For location messages, we need to:

  1. Create a custom attachment view catalog
  2. Create a location attachment view injector
  3. Create a location snapshot view
  4. Handle location interactions

Setting up the attachment view catalog

First, create a custom attachment view catalog that detects location messages and uses the custom view injector:

class CustomAttachmentViewCatalog: AttachmentViewCatalog {
    override class func attachmentViewInjectorClassFor(message: ChatMessage, components: Components) -> AttachmentViewInjector.Type? {
        let hasLocationAttachment = message.sharedLocation != nil
        if hasLocationAttachment {
            return LocationAttachmentViewInjector.self
        }
        return super.attachmentViewInjectorClassFor(message: message, components: components)
    }
}

// Register the custom catalog
Components.default.attachmentViewCatalog = CustomAttachmentViewCatalog.self

Creating the location attachment view injector

The view injector is responsible for the following:

  • Adding the location view to the message view
  • Forwarding the location tap to the message list delegate
  • Forwarding the stop sharing location tap to the message list delegate

Here’s the full implementation:


class LocationAttachmentViewInjector: AttachmentViewInjector {
    lazy var locationAttachmentView = LocationAttachmentSnapshotView()
    let mapWidth: CGFloat = 300

    override func contentViewDidLayout(options: ChatMessageLayoutOptions) {
        super.contentViewDidLayout(options: options)

        contentView.bubbleContentContainer.insertArrangedSubview(locationAttachmentView, at: 0)
        contentView.bubbleThreadFootnoteContainer.width(mapWidth)

        locationAttachmentView.didTapOnLocation = { [weak self] in
            self?.handleTapOnLocationAttachment()
        }
        locationAttachmentView.didTapOnStopSharingLocation = { [weak self] in
            self?.handleTapOnStopSharingLocation()
        }

        let isSentByCurrentUser = contentView.content?.isSentByCurrentUser == true
        let maskedCorners: CACornerMask = isSentByCurrentUser
            ? [.layerMinXMaxYCorner, .layerMinXMinYCorner]
            : [.layerMinXMinYCorner, .layerMaxXMaxYCorner, .layerMaxXMinYCorner]
        locationAttachmentView.layer.maskedCorners = maskedCorners
        locationAttachmentView.layer.cornerRadius = 16
        locationAttachmentView.layer.masksToBounds = true
    }

    override func contentViewDidUpdateContent() {
        super.contentViewDidUpdateContent()

        if let message = contentView.content, let location = message.sharedLocation {
            locationAttachmentView.content = .init(
                message: message,
                location: location
            )
        }
    }

    func handleTapOnLocationAttachment() {
        guard let locationAttachmentDelegate = contentView.delegate as? LocationAttachmentViewDelegate else {
            return
        }

        guard let location = contentView.content?.sharedLocation else {
            return
        }

        locationAttachmentDelegate.didTapOnLocation(location)
    }

    func handleTapOnStopSharingLocation() {
        guard let locationAttachmentDelegate = contentView.delegate as? LocationAttachmentViewDelegate else {
            return
        }

        guard let location = contentView.content?.sharedLocation else {
            return
        }

        locationAttachmentDelegate.didTapOnStopSharingLocation(location)
    }
}

Creating the location snapshot view

The location snapshot view displays a map snapshot of the location. Here’s the essential structure:

class LocationAttachmentSnapshotView: _View, ThemeProvider {
    struct Content {
        var coordinate: CLLocationCoordinate2D
        var isLive: Bool
        var isSharingLiveLocation: Bool
        var message: ChatMessage?
        var author: ChatUser?
    }

    var content: Content? { didSet { updateContent() } }
    var didTapOnLocation: (() -> Void)?
    var didTapOnStopSharingLocation: (() -> Void)?

    // UI Components
    lazy var imageView: UIImageView = { /* Map snapshot display */ }()
    lazy var stopButton: UIButton = { /* Stop sharing button for live locations */ }()
    lazy var avatarView: ChatUserAvatarView = { /* User avatar for live locations */ }()
    lazy var sharingStatusView: LocationSharingStatusView = { /* Live status indicator */ }()

    override func updateContent() {
        super.updateContent()
        guard let content = self.content else { return }

        // Show/hide UI elements based on location type
        if content.isSharingLiveLocation && content.isFromCurrentUser {
            stopButton.isHidden = false
            sharingStatusView.isHidden = true
        } else if content.isLive {
            stopButton.isHidden = true
            sharingStatusView.isHidden = false
        } else {
            stopButton.isHidden = true
            sharingStatusView.isHidden = true
        }

        configureMapPosition()
        loadMapSnapshotImage()
    }
}

This view will generate a snapshot image of the location and display it in the message cell. The snapshot is generated using MKMapSnapshotter and cached for performance.

For the full implementation, you can check the LocationAttachmentSnapshotView in the Demo App.

Note: When quoting a message with a location attachment, by default it won’t display anything. You will need to customize the QuotedChatMessageView, for example:

class DemoQuotedChatMessageView: QuotedChatMessageView {
    override func updateContent() {
        super.updateContent()

        if let sharedLocation = content?.message.sharedLocation {
            if sharedLocation.isLive {
                attachmentPreviewView.contentMode = .scaleAspectFit
                attachmentPreviewView.image = UIImage(systemName: "location.fill")
                attachmentPreviewView.tintColor = .systemBlue
                textView.text = "Live Location"
            } else {
                attachmentPreviewView.contentMode = .scaleAspectFit
                attachmentPreviewView.image = UIImage(systemName: "mappin.circle.fill")
                attachmentPreviewView.tintColor = .systemRed
                textView.text = "Static Location"
            }
        }
    }
}

Components.default.quotedMessageView = DemoQuotedChatMessageView.self

Handling location interactions

We need to handle the location interactions that were forwarded by the view injector. We do this by extending the ChatMessageContentViewDelegate with two new methods, didTapOnLocation and didTapOnStopSharingLocation.

protocol LocationAttachmentViewDelegate: ChatMessageContentViewDelegate {
    func didTapOnLocation(_ location: SharedLocation)
    func didTapOnStopSharingLocation(_ location: SharedLocation)
}

class DemoChatMessageListVC: LocationAttachmentViewDelegate {
    func didTapOnLocation(_ location: SharedLocation) {
        let messageController = client.messageController(
            cid: location.channelId,
            messageId: location.messageId
        )
        showDetailViewController(messageController: messageController)
    }

    func didTapOnStopSharingLocation(_ location: SharedLocation) {
        client
            .messageController(cid: location.channelId, messageId: location.messageId)
            .stopLiveLocationSharing()
    }

    private func showDetailViewController(messageController: ChatMessageController) {
        let mapViewController = LocationDetailViewController(
            messageController: messageController
        )
        navigationController?.pushViewController(mapViewController, animated: true)
    }
}

// Register the custom message list
Components.default.messageListVC = DemoChatMessageListVC.self

Full map view with live updates

Now that we have everything mostly set up, the only thing left to do is to create a view controller that will display the map and the live location updates.

We will use the ChatMessageController to notify us whenever the location message changes. Below you will find the essential code to display the map and the live location updates. For the full implementation, you can check the LocationDetailViewController in the Demo App.

class LocationDetailViewController: UIViewController, ThemeProvider {
    let messageController: ChatMessageController

    init(
        messageController: ChatMessageController
    ) {
        self.messageController = messageController
        super.init(nibName: nil, bundle: nil)
    }

    private var userAnnotation: UserAnnotation?

    let mapView: MKMapView = {
        let view = MKMapView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.isZoomEnabled = true
        view.showsCompass = false
        return view
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Location"

        messageController.synchronize()
        messageController.delegate = self

        mapView.register(
            UserAnnotationView.self,
            forAnnotationViewWithReuseIdentifier: UserAnnotationView.reuseIdentifier
        )
        mapView.showsUserLocation = !isFromCurrentUser
        mapView.delegate = self

        view.backgroundColor = appearance.colorPalette.background
        view.addSubview(mapView)
        setUpConstraints()

        var locationCoordinate: CLLocationCoordinate2D?
        if let location = messageController.message?.sharedLocation {
            locationCoordinate = CLLocationCoordinate2D(
                latitude: location.latitude,
                longitude: location.longitude
            )
        }

        if let locationCoordinate {
            mapView.region = .init(
                center: locationCoordinate,
                span: coordinateSpan
            )
            updateUserLocation(
                locationCoordinate
            )
        }
    }
}

extension LocationDetailViewController: ChatMessageControllerDelegate {
    func messageController(
        _ controller: ChatMessageController,
        didChangeMessage change: EntityChange<ChatMessage>
    ) {
        guard let location = messageController.message?.sharedLocation, location.isLive else {
            return
        }

        let locationCoordinate = CLLocationCoordinate2D(
            latitude: location.latitude,
            longitude: location.longitude
        )

        updateUserLocation(
            locationCoordinate
        )

        let isLiveLocationSharingStopped = location.isLiveSharingActive == false
        if isLiveLocationSharingStopped, let userAnnotation = self.userAnnotation {
            let userAnnotationView = mapView.view(for: userAnnotation) as? UserAnnotationView
            userAnnotationView?.stopPulsingAnimation()
        }

        updateBannerState()
    }
}

extension LocationDetailViewController: MKMapViewDelegate {
    func mapView(
        _ mapView: MKMapView,
        viewFor annotation: MKAnnotation
    ) -> MKAnnotationView? {
        guard let userAnnotation = annotation as? UserAnnotation else {
            return nil
        }

        let annotationView = mapView.dequeueReusableAnnotationView(
            withIdentifier: UserAnnotationView.reuseIdentifier,
            for: userAnnotation
        ) as? UserAnnotationView
        annotationView?.setUser(userAnnotation.user)

        let location = messageController.message?.sharedLocation
        if location?.isLiveSharingActive == true {
            annotationView?.startPulsingAnimation()
        } else {
            annotationView?.stopPulsingAnimation()
        }
        return annotationView
    }
}

For the most part, this implementation will suit most use cases. However, you can customize it to your needs, for example, you can also show all active live location messages in the same map. For this you will need to use the channel.activeLiveLocations property and use the EventsController to listen for message events and update the location messages accordingly.

Conclusion

In this guide, we’ve covered how to integrate location sharing functionality into a UIKit-based chat application using the Stream Chat SDK. The SDK provides the core functionality for sending, receiving, and managing location messages, while giving you the flexibility to create custom UI components that match your app’s design.

Key points to remember:

  • The SDK handles location message lifecycle management
  • Your app is responsible for location tracking and UI components
  • Both static and live location sharing are supported
  • Events allow real-time updates of location messages
© Getstream.io, Inc. All Rights Reserved.