Rewriting the WriteFreely for iOS Post Editor

(Or, A Journey Into UIViewRepresentable)

The heart of any writing app, as you might imagine, is the area where you, y’know, write.

In the initial release of WriteFreely for iOS, the app used two SwiftUI elements for the post editor. A single-line TextField was used for the post’s title, and below it, a multi-line TextEditor was used for the post’s body content.

That worked well enough for an initial 1.0 release, and the plan was improve it significantly in a subsequent 1.0.1 patch release. With that new release now in beta testing, I figured it’d be fun to dive into the refactoring work I did.

What’s Wrong With the Editor?

Technically, I suppose that there wasn’t anything wrong with the old post editor — but there wasn’t much to write home about, either.

(Yes, I made a writing pun, in a post about a text editor. Please don’t email me.)

TextField elements are very useful for things like forms, and TextEditor elements work fine for longer bits of text. The former can show placeholder text, but the latter can’t, so to get that “Write…” text to show up in a new blank post requires a hack-y solution with conditionally presenting views on the z-index if there’s no text.

Really, the biggest issue with the old editor was my own fault: in the run-up to launch, there were so many things changed and tacked on that it was in dire need of a good refactoring to make it maintainable. And if you’re going to refactor a major part of your app, well, you may as well make it ✨sparkle ✨, right?

Planned Improvements

For all the benefits you get building with stock SwiftUI controls like TextField and TextEditor, you miss out on some of the very convenient features of UITextField and UITextView. Things like being able to become or resign as first responder — something that would allow the app to focus the cursor in the title field and bring up the iOS keyboard when a post is loaded into the editor.

And, just like in Write.as' Classic editor (requires logging to Write.as to view), tapping the Return key from the title field should jump to the body field. Moreover, that title field needed some UI love — rather than show only a single line and truncate whatever didn’t fit on screen, it should expand in height as the title reached the end of the screen and wrapped over to another line.

Out With the New, In With the Old

To enable all of this, the stock SwiftUI TextField and TextEditor controls were replaced with two custom controls, PostTitleTextView and PostBodyTextView that conform to the UIViewRepresentable protocol and wrap good ol’ UITextView.

Before discussing the specifics of UIViewRepresentable, a high-level view of how the post editor is structured around PostTitleTextView and PostBodyTextView might be helpful.

Both are embedded in a PostTextEditingView, which is responsible for the text-editing work of the post editor. It handles state for things like the height of the title text view, switching first-responder status between the title and the body, and the appropriate font for the post. It also provides the placeholder-text implementation for both of the text views.

This editing view is embedded in the top-level PostEditorView, which is where all of the old editor’s code used to live; it now handles high-level stuff like the toolbar and menu, listening for changes to the post you’re editing, and syncing with CoreData.

So, given that overview of the editor’s setup, back to the nitty-gritty. The UIViewRepresentable protocol essentially gives developers a way to bridge between SwiftUI and UIKit on iOS and AppKit on the Mac. To get UITextView working in the WriteFreely app required implementing three protocol methods (makeUIView(context:), updateUIView(_:context:), and makeCoordinator()), and a coordinator class that conforms to the UITextViewDelegate and NSLayoutManager protocols.

One of the key reasons for moving the editor to this translated UITextView was to access the responder chain. This is what brings up the keyboard and gets the device ready for typing when a text input control is added to the view, and similarly what dismisses the keyboard when that control is removed from the view hierarchy. Adding it was done primarily the same way as proposed in this Stack Overflow answer, except that there’s a subtle bug here: if you add an emoji to your text, then move the text-insertion cursor to some point before the end of the text string and start typing, it’ll insert the first character where you expect, and then jump to the end of the text string. 🙈

The fix (many thanks to Marc Palmer for this!) is to conditionally update the UITextView’s text property only if it doesn’t match the string binding in the updateUIView function:

if uiView.text != text {
   uiView.text = text
}

That little change makes everything work beautifully.

Expanding The Title Field

The old editor had a basic, single-line text field that just kept scrolling as you typed, and —especially annoying on iPhone— only showed you as much of the title as your screen could fit across its width.

With the new editor, the PostTitleTextView’s coordinator includes a call to layoutManager(_: didCompleteLayoutFor: atEnd:), where the size of the text view’s height is updated as the line wraps:

func layoutManager(
   _ layoutManager: NSLayoutManager,
   didCompleteLayoutFor textContainer: NSTextContainer?,
   atEnd layoutFinishedFlag: Bool
) {
   DispatchQueue.main.async {
       guard let view = self.textView else { return }
       let size = view.sizeThatFits(view.bounds.size)
       if self.postTitleTextView.height != size.height {
           self.postTitleTextView.height = size.height
       }
   }
}

This works in conjunction with a titleTextHeight state property and titleFieldHeight computed property in the PostTextEditingView container to specify a minimum height for the title field, and, as lines are added, update the height of PostTitleTextView. Credit and many thanks to Natalia Panferova on the Lost Moa blog!

What’s Next

So with these improvements to the editor done, what’s next? While we maintain a list of GitHub issues of work to be done, it doesn’t really provide much of a solid road map. With v1.0.1 of the iOS app now available on the App Store, it’s time to really focus on fulfilling that promise of SwiftUI and get a Mac app shipped at feature parity with the iOS app.

The experience of porting the iOS editor from SwiftUI over to UIViewRepresentable-conforming UITextViews is going to be very helpful, I think, in solving a very annoying input-lag issue with TextEditor on the Mac. I’m looking forward to getting that shipped ASAP!


Enter your email to subscribe to updates:

You can also subscribe via RSS or follow @angelo@write.as on Mastodon.