Updating layouts for iPhone X

Apple generally provides us as developers plenty of notice to make updates, whether explicit or implied. There are a number of these that I have been doing slowly this year for various reasons—High Sierra, Swift 4, iPhone X layout. Starting at WWDC, we could have been updating our apps to be prepared for the iPhone X layout changes, although none of us knew exactly what to expect. All we knew was that “safe area layout guides” were a thing and we should be using them.

Since receiving my own iPhone X, I have been much more motivated to get my own apps updated to work better with the unique layout challenges. I wanted to capture some of the standard changes I find myself making repeatedly.

Note: I make heavy use of storyboards, so most of my explanations will involve Interface Builder.

Enable safe area layout guides

The first crucial step is to enable safe area layout guides in the storyboard. When updating from Xcode 8 to Xcode 9, this setting will be off by default, while any new storyboards created in Xcode 9 will have it enabled by default. Simply open the file inspector (the first inspector in the right pane in Xcode), and check the box named “Use Safe Area Layout Guides” to enable them.

Enable safe area layout guides

Table views

Through the content view in UITableViewCell, table views work almost magically with the iPhone X. One of the main changes I have found is that the table view itself needs to be constrained to the actual view of the view controller, and not the safe area. If you had the table view constrained to the edges in Xcode 8, when you enable the safe area layout guides, those constraints will be converted to be relative to the safe area. For a table view, you don’t want that. You need to go in and modify each constraint to be relative to the superview instead.

Constrain to superview

Custom separators

Many of my custom table view cells will have a custom separator line. This is common if different cells in the same table need to have different separators. Typically, those views were constrained to the content view of the cell. However, that will leave the separators inset when in landscape mode because of the safe area layout guides. Instead, constrain the views to the cell itself. The exception to this rule is for separators that should be inset, typically on the leading edge. In that case, the leading edge of the view should remain constrained to the content view of the cell in order to retain the proper inset.

Constrain to cell

Extra bottom view

Many designs have a view at the bottom of the screen. This view would typically be constrained to the bottom layout guide previously, and that constraint would be converted to be relative to the safe area. This will leave an unsightly gap at the bottom of the screen.

Gap at bottom Bottom view constrained to safe area

There are two solutions I have used to correct this situation. The first is to simply add an extra UIView with a matching background color below the original view. That view will be constrained to the leading and trailing of the original view, and the bottom will be constrained to the superview—not the safe area. Then with a vertical spacing constraint between the views, you are set. When on a device where the safe area is the bottom of the device, the extra view will have a height of 0. On the iPhone X, the extra view will go from the bottom of the screen up to the bottom of the safe area.

The other solution is very similar, but instead of placing the view below your original view, it is embedded inside of it with a clear background color. Then you change the constraints of the original view to be to the superview and not the safe area. Then any views you have inside of your original view will have their bottom relative to this new spacer view. This is definitely the preferred solution if your original view is a visual effect view.

Extra bottom view

Summary

There are many more adjustments to consider when updating an app for the iPhone X, but these are the most common situations I have encountered. I look forward to having my apps feel at home on this new device.


Handling live text reload elegantly

In my current project at my day job, we are using Firebase and ReSwift. I plan to write more about this powerful combination soon. One of the major advantages is that it allows us to easily support live reloading of concurrent editing. However, I ran into a problem in long-form text editing. It was impressive to see the text update while someone else edited the same data, but if you were also trying to type, it would get extremely frustrating. With every reload, your cursor would jump to the end of the text, making it nearly impossible to keep working.

One of my favorite podcasts, Runtime, recently mentioned approaches to diffing text. I reached out to Sam Soffes, who pointed me to a simple library he created for this, diff.

Using that library, I was ready to tackle preserving the cursor position and text selection when the underlying text changed. In the hopes that others can benefit from or improve this work, here is the code that I am using:

func updateText(with newString: String?) {
    guard let textView = textView, newString = newString,
      (diffRange, changedText) = diff(textView.text, newString) else { return }
    guard let selectedRange = textView.selectedTextRange else { textView.text = newString; return }
    textView.text = newString

    let cursorOffset = textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.start)
    let selectedEndOffset = textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.end)
    let selectedRangeLength = selectedEndOffset - cursorOffset

    if selectedEndOffset < diffRange.startIndex {
        // Change is after current cursor
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength)
    } else if cursorOffset < diffRange.startIndex && selectedEndOffset > diffRange.endIndex {
        // Change occurs within selection
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength + changedText.characters.count - diffRange.count)
    } else if cursorOffset >= diffRange.endIndex {
        // Change occurs completely before current cursor
        moveCursorRelativeToBeginning(with: cursorOffset + changedText.characters.count - diffRange.count, rangeLength: selectedRangeLength)
    } else if diffRange.startIndex < selectedEndOffset && diffRange.startIndex > cursorOffset {
        // Change starts in middle of selection
        moveCursorRelativeToBeginning(with: cursorOffset, rangeLength: selectedRangeLength - (selectedEndOffset - diffRange.startIndex))
    } else if diffRange.startIndex <= cursorOffset && cursorOffset < diffRange.endIndex {
        // Change is a removal/change over the current cursor position
        let rangeLength = selectedRangeLength - (diffRange.endIndex - cursorOffset)
        moveCursorRelativeToBeginning(with: cursorOffset - (cursorOffset - diffRange.startIndex) + changedText.characters.count, rangeLength: rangeLength > 0 ? rangeLength : 0)
    }
}

private func moveCursorRelativeToBeginning(with offset: Int, rangeLength: Int = 0) {
    guard let textView = textView, startPosition = textView.positionFromPosition(textView.beginningOfDocument, offset: offset), endPosition = textView.positionFromPosition(startPosition, offset: rangeLength) else { return }
    textView.selectedTextRange = textView.textRangeFromPosition(startPosition, toPosition: endPosition)
}

This is even more useful when combined with the automated tests that ensure that it is working properly. Here are all of the tests:

import XCTest
import Nimble
import Diff
@testable import align

class TextEditingSpec: XCTestCase {

    var textEditing: TextEditing!

    override func setUp() {
        super.setUp()
        textEditing = TextEditing.initializeFromStoryboard()
        let _ = textEditing.view
        textEditing.textView = UITextView()
    }

    /// test that it loads properly
    func testThatItLoadsProperly() {
        expect(self.textEditing.textView).toNot(beNil())
        expect(self.textEditing.textView?.text) == ""
        expect(self.textEditing.title).to(beNil())
    }


    // MARK: - Cursor position tests

    // Original text: "Watch Bugger attack videos together and discuss strategy."

    /// test that cursor position does not change if state changes but agenda is unchanged
    func testThatCursorPositionDoesNotChangeIfStateChangesButAgendaIsUnchanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0
}

    /// test that cursor position does not change when agenda has changes after cursor
    func testThatCursorPositionDoesNotChangeWhenAgendaHasChangesAfterCursor() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch Bugger attack videos together.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has removed text before current cursor position
    func testThatCursorPositionChangesWhenAgendaHasRemovedTextBeforeCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 4
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has changed text before current cursor position
    func testThatCursorPositionChangesWhenAgendaHasChangedTextBeforeCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "View Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 9
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has removed text that includes current cursor position
    func testThatCursorPositionChangesWhenAgendaHasRemovedTextThatIncludesCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "Watch attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor position changes when agenda has changed text that includes current cursor position
    func testThatCursorPositionChangesWhenAgendaHasChangedTextThatIncludesCurrentCursorPosition() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 10)
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 0

        textEditing.updateText(with: "View recorded Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 18
        expect(self.selectedRangeLength()) == 0
    }


    // MARK: - Selected text tests

    /// test that text selection does not changes when text does not change
    func testThatTextSelectionDoesNotChangesWhenTextDoesNotChange() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection does not change when text changes occur after selection
    func testThatTextSelectionDoesNotChangeWhenTextChangesOccurAfterSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bugger attack videos and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection remains same but moves when text is added before selection
    func testThatTextSelectionRemainsSameButMovesWhenTextIsAddedBeforeSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch the Bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 10
        expect(self.selectedRangeLength()) == 6
    }

    /// test that text selection adjusts to include changes that occur within the selection
    func testThatTextSelectionAdjustsToIncludeChangesThatOccurWithinTheSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bear attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 4
    }

    /// test that text selection expands to include additions that occur within the selection
    func testThatTextSelectionExpandsToIncludeAdditionsThatOccurWithinTheSelection() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Big bad bugger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 14
    }

    /// test that text selection is truncated when the end of the selection is removed
    func testThatTextSelectionIsTruncatedWhenTheEndOfTheSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bug attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 3
    }

    /// test that text selection is truncated when the end of the selection is changed
    func testThatTextSelectionIsTruncatedWhenTheEndOfTheSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch Bug vehicle attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 3
    }

    /// test that text selection is truncated when the beginning of the selection is removed
    func testThatTextSelectionIsTruncatedWhenTheBeginningOfTheSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch er attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 2
    }

    /// test that text selection is truncated and moved when the beginning of the selection is changed
    func testThatTextSelectionIsTruncatedAndMovedWhenTheBeginningOfTheSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watching some tiger attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 16
        expect(self.selectedRangeLength()) == 3
    }

    /// test that cursor does not move when the exact selection is removed
    func testThatCursorDoesNotMoveWhenTheExactSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watch attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor is moved when entire selection is removed
    func testThatCursorIsMovedWhenEntireSelectionIsRemoved() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Wattack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 3
        expect(self.selectedRangeLength()) == 0
    }

    /// test that cursor is moved when entire selection is changed
    func testThatCursorIsMovedWhenEntireSelectionIsChanged() {
        textEditing.textView?.text = "Watch Bugger attack videos together and discuss strategy."
        textEditing.textView?.becomeFirstResponder()
        moveCursorRelativeToBeginning(with: 6, length: 6)
        expect(self.cursorOffset()) == 6
        expect(self.selectedRangeLength()) == 6

        textEditing.updateText(with: "Watching attack videos together and discuss strategy.")
        expect(self.cursorOffset()) == 8
        expect(self.selectedRangeLength()) == 0
    }

}


// MARK: - Private functions

private extension TextEditingSpec {

    private func cursorOffset() -> Int {
        guard let textView = textEditing.textView, selectedRange = textView.selectedTextRange else { return 0 }
        return textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.start)
    }

    private func selectedRangeLength() -> Int {
        guard let textView = textEditing.textView, selectedRange = textView.selectedTextRange else { return 0 }
        return textView.offsetFromPosition(textView.beginningOfDocument, toPosition: selectedRange.end) - cursorOffset()
    }

    private func moveCursorRelativeToBeginning(with offset: Int, length: Int = 0) {
        guard let textView = textEditing.textView, startPosition = textView.positionFromPosition(textView.beginningOfDocument, offset: offset), endPosition = textView.positionFromPosition(startPosition, offset: length) else { return }
        textView.selectedTextRange = textView.textRangeFromPosition(startPosition, toPosition: endPosition)
    }

}

And in case it is easier to consume, I created a Gist with all of the code.

Edit: Since writing this, I decided to pull all of this code into a simple library: TextMagic.