How to Build a Collaborative Text Editor in Swift

Collaborative editing allows multiple people to work together on the same document in realtime. Google Docs is a well-known example – changes made by each collaborator are instantly visible to everyone else. This post will explain how to build your own collaborative text editor as an iOS app using Swift.

We‘ll cover:

  • The architecture of a realtime collaborative editor
  • How to build a simple text editor interface in Swift
  • Syncing changes between collaborators instantly
  • Gracefully handling conflicts
  • Additional features to enhance the collaborative experience

By the end, you‘ll understand how the pieces fit together and be ready to create your own collaborative Swift app. Let‘s get started!

Architecture Overview

At a high level, a realtime collaborative editor needs a way to:

  1. Accept changes from each collaborator
  2. Broadcast those changes to all other collaborators
  3. Merge changes into a single document while handling any conflicts

We‘ll use the following technologies to achieve this:

Swift iOS app: The frontend that contains the text editor interface. As a user types, their changes will be sent to the backend API. The app will also listen for changes from other collaborators and merge them into the local document.

Backend API: A server that receives document changes from each collaborator‘s app and broadcasts them to all other collaborators. We‘ll write this in JavaScript using Node.js and Express.

Realtime messaging: We need a way to instantly push changes from the backend API to each collaborator‘s app. We‘ll use the Pusher service for this. When the API receives a change, it will send it to Pusher, which will then forward it to all the apps subscribed to that document.

Here‘s a diagram of how the pieces interact:

Architecture diagram of collaborative text editor

With this architecture in mind, let‘s start building the Swift app.

Creating the Swift Text Editor Interface

First we‘ll create a basic text editor interface using a UITextView. Create a new iOS app project in Xcode and add a UITextView to your main storyboard. Create an outlet in your view controller:

class EditorViewController: UIViewController {

    @IBOutlet weak var textView: UITextView!

    override func viewDidLoad() {
        super.viewDidLoad()
    }

}

We want to send the text to our backend API whenever it changes. To do this, make your view controller conform to the UITextViewDelegate protocol and implement the textViewDidChange method:

extension EditorViewController: UITextViewDelegate {

    func textViewDidChange(_ textView: UITextView) {
        sendTextToAPI()
    }

    func sendTextToAPI() {
        // TODO: Implement
    }

}

Don‘t forget to set your view controller as the text view‘s delegate:

override func viewDidLoad() {
    super.viewDidLoad()

    textView.delegate = self        
}

Build and run the app. You should see a full screen text view. As you type, the textViewDidChange method will be called. Next let‘s implement sending the changes to our backend API.

Sending Text Changes to the Backend API

To send the text to our API, we‘ll use URLSession to make a POST request. Add this code to your sendTextToAPI method:

func sendTextToAPI() {
    let text = textView.text

    let url = URL(string: "https://your-api-url.com/documents/your-doc-id")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    let data = ["text": text]
    let body = try? JSONSerialization.data(withJSONObject: data)
    request.httpBody = body

    let task = URLSession.shared.dataTask(with: request) { data, response, error in
        // Handle response        
    }
    task.resume()
}

Be sure to replace "your-api-url.com" and "your-doc-id" with your actual API endpoint and document ID. This will POST the full text to the API every time it changes.

You may want to debounce or throttle this to avoid sending too many requests if the user is typing quickly. You can use a timer to only send if 0.5 seconds have passed since the last change, for example.

Listening for Text Changes from the API

Now we need to get changes from other collaborators and merge them into our document. We‘ll use the Pusher service to get realtime updates pushed to us from the backend API.

First, add the Pusher Swift library to your project using CocoaPods. Create a Podfile with this content:

target ‘YourAppName‘ do
  use_frameworks!

  pod ‘PusherSwift‘, ‘~> 10.1‘

end

Then run pod install to pull in the dependency. Be sure to open the .xcworkspace file instead of .xcodeproj from now on.

In your view controller, create a PusherDelegate property and subscribe to your document channel in viewDidLoad:

import PusherSwift

class EditorViewController: UIViewController {

  let pusherDelegate = PusherDelegate()

  override func viewDidLoad() {
    // Existing setup code...

    let pusherOptions = PusherClientOptions.init(authMethod: AuthMethod.endpoint(authEndpoint: "https://your-api-url.com/pusher/auth"))
    let pusherClient = Pusher(key: "YOUR_PUSHER_API_KEY", options: pusherOptions)
    let channel = pusherClient.subscribe("private-your-doc-id")
    let _ = channel.bind(eventName: "client-text-update") { event in
        self.pusherDelegate.didReceiveTextUpdate(event.data)
    }
    pusherClient.connect()
  }

}

This subscribes to a private channel for your document and binds to a custom "client-text-update" event. When an event comes in, it‘s forwarded to our PusherDelegate class.

Here‘s what PusherDelegate looks like:

class PusherDelegate {

    weak var editorViewController: EditorViewController?

    func didReceiveTextUpdate(_ jsonString: String) {
        guard let data = jsonString.data(using: .utf8),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let text = json["text"] as? String
        else {
            print("Failed to parse text update \(jsonString)")
            return
        }
        mergeTextUpdate(newText: text) 
    }

    func mergeTextUpdate(newText: String) {
        DispatchQueue.main.async { [weak self] in
            guard let strongSelf = self,
                  let vc = strongSelf.editorViewController
            else { return }
            vc.textView.text = newText
        }
    }

}

When it receives a new text update event, it parses the JSON data, extracts the text, and sets it on the view controller‘s textView. This will replace the local text with the latest version from the server.

Handling Text Merging and Conflicts

The basic collaborative editor is working now, but there are some important problems to solve:

  1. Local changes will be constantly overwritten by server changes, causing a jarring experience and potential loss of data.
  2. If collaborators make changes to the same part of the document at the same time, one of the changes will be lost.

To address #1, we need to merge changes from the server into the local text more intelligently. When the server sends us new text, we can use a diffing library to find the changed ranges and only replace those parts of the local text.

One good Swift diffing library is Dwifft. It lets you pass two arrays and returns the indexes that changed.

Here‘s how we could use Dwifft to properly merge text changes coming from the server into our local document:

func mergeTextUpdate(newText: String) {
    DispatchQueue.main.async { [weak self] in
        guard let vc = self?.editorViewController else { return }

        let localTextChars = Array(vc.textView.text)
        let remoteTextChars = Array(newText) 

        let diffs = localTextChars.diff(remoteTextChars)
        var mergedText = localTextChars

        for diff in diffs {
            switch diff {
            case let .insert(i, _):
                mergedText.insert(remoteTextChars[i], at: i)
            case let .remove(i, _):
                mergedText.remove(at: i)
            }
        }

        vc.textView.text = String(mergedText)
    }
}

This will merge in only the changes from the server, preserving the local modifications as well.

For #2, we need the changes coming from the server to include more information than just the full text. The backend API should compute the changed ranges and send those to the clients.

The API would use a diffing library to find the ranges of text that each collaborator changed. Then it would merge the sets of changes together, resolving any overlapping edits, before broadcasting the final set of changes to all collaborators.

Each client would then apply the changes to its local text, being careful to adjust the ranges to account for any local modifications that happened in the meantime. This is essentially what Google Docs and other collaborative editors do.

Implementing conflict-free merging and complex diffing is outside the scope of this post, but hopefully this gives you an idea of what‘s involved. There are open source libraries that can help with this piece, like Differential Synchronization.

Additional Collaborative Editing Features

With the basic real-time collaboration in place, you can add more features to enhance the experience:

Collaborator Presence: Show an indicator of where each person‘s cursor is in the document. You‘ll need the backend API to include that info in the broadcasted updates. The Swift app would render cursors and names in the appropriate spot.

Automatic Reconnection: If the user‘s internet connection drops, try to automatically reconnect to the API when they come back online. You may need to retrieve the latest document state and carefully merge it with any local changes made while offline.

Undo/Redo Stack: Keep track of each collaborator‘s changes separately in an undo/redo stack. This is tricky to get right, but it allows each user to undo their own changes without affecting others.

Saving: Add an auto-save feature that periodically sends the full document state to the server for persistent storage, in case of app crashes or other problems.

Next Steps

I hope this post gave you a solid foundation for creating your own collaborative text editor using Swift! To recap, we covered:

  • The high-level architecture (Swift app, backend API, realtime sync with Pusher)
  • Implementing a basic text editor UI in Swift
  • Sending local changes to the backend API
  • Receiving changes from other collaborators via Pusher
  • Merging changes into the local document
  • Handling collaborative editing edge cases
  • Potential additional features to enhance the experience

To learn more, I recommend checking out these resources:

If you have any questions or ideas for a follow-up post, let me know! Happy collaborative editing!

Similar Posts