iOS UITextView Text Wrap Editor|Swift Implementation for Seamless Layout
Discover how to implement a UITextView text wrap editor in Swift that resolves layout challenges and enhances text editing efficiency. This guide helps developers streamline text flow around images, improving user experience and app performance.
点击这里查看本文章简体中文版本。
點擊這裡查看本文章正體中文版本。
This post was translated with AI assistance — let me know if anything sounds off!
iOS UITextView Text Wrapping Editor (Swift)
Practical Route
Goal Function:
The app has a forum feature that allows users to post articles. The posting interface needs to support text input, inserting multiple images, and text wrapping around images.
Functional Requirements:
Can enter multiple lines of text
Can insert images inline within text
Can upload multiple images
Can freely remove inserted images
Image upload success/failure handling
Can convert input content into transferable text, e.g., BBCODE
Here’s the final result image:
Start:
Chapter 1
What? You said Chapter One? Isn’t it enough to use UITextView to create an editor? Why bother dividing it into “chapters”? Yes, that was my initial reaction too, until I started working on it and realized it wasn’t that simple. I struggled for two weeks, flipping through various domestic and international resources before finding a solution. Let me share the journey of my implementation…
If you want to see the final solution directly, please skip to the last chapter (scroll down, down, down, down, down).
At the Beginning
The text editor naturally uses the UITextView component. According to the documentation, UITextView’s attributedText includes NSTextAttachment objects that can attach images to achieve text wrapping effects. The code is also very simple:
1
2
3
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: "example")
self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)
At first, I was naive and happy, thinking it was quite simple and convenient; but the real problems are just beginning:
Images must be selectable and uploadable from local storage: This is easy to solve. For the image picker, I use the TLPhotoPicker library (supports multiple image selection, customization, camera switching, and Live Photos). The approach is to convert the PHAsset to UIImage after TLPhotoPicker’s selection callback, assign it to imageAttachment.image, and upload the image to the server in the background in advance.
Image upload needs to show effects and allow interactive actions (click to view original image / click X to delete): not achieved yet, unable to find a way with NSTextAttachment to fulfill this requirement. However, it’s okay without this feature since images can still be deleted (pressing the “Back” key on the keyboard after the image deletes it). Let’s continue…
Original image files are too large, causing slow upload, slow insertion, and high resource use: Resize them before uploading and inserting using Kingfisher’s resizeTo function.
Insert the image at the cursor position: Here, the original code should be changed as follows
1
2
3
4
let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) // Get current content
combination.insert(NSAttributedString(attachment: imageAttachment), at: range)
self.contentTextView.attributedText = combination // Write back
- Image Upload Failure Handling: Here, I want to mention that I actually wrote a separate class to extend the original NSTextAttachment, aiming to add an extra property to store an identification value.
1
2
3
class UploadImageNSTextAttachment:NSTextAttachment {
var uuid:String?
}
Change to when uploading images:
1
2
3
let id = UUID().uuidString
let attachment = UploadImageNSTextAttachment()
attachment.uuid = id
If we can identify the corresponding NSTextAttachment, we can search for the NSTextAttachment in the attributedText for the images that failed to upload, find them, and replace them with an error placeholder image or remove them directly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
if let content = self.contentTextView.attributedText {
content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
if object.keys.contains(NSAttributedStringKey.attachment) {
if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment, attachment.uuid == "targetID" {
attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
attachment.image = UIImage(named: "IconError")
let combination = NSMutableAttributedString(attributedString: content)
combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
// To remove directly, use deleteCharacters(in: range)
self.contentTextView.attributedText = combination
}
}
}
}
After overcoming the above issues, the code will look roughly like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class UploadImageNSTextAttachment:NSTextAttachment {
var uuid:String?
}
func dismissPhotoPicker(withTLPHAssets: [TLPHAsset]) {
// Callback for TLPhotoPicker image picker
let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
// Get cursor position, default to start if none
guard withTLPHAssets.count > 0 else {
return
}
DispatchQueue.global().async { in
// Process in background
let orderWithTLPHAssets = withTLPHAssets.sorted(by: { $0.selectedOrder > $1.selectedOrder })
orderWithTLPHAssets.forEach { (obj) in
if var image = obj.fullResolutionImage {
let id = UUID().uuidString
var maxWidth:CGFloat = 1500
var size = image.size
if size.width > maxWidth {
size.width = maxWidth
size.height = (maxWidth/image.size.width) * size.height
}
image = image.resizeTo(scaledToSize: size)
// Resize image
let attachment = UploadImageNSTextAttachment()
attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
attachment.uuid = id
DispatchQueue.main.async {
// Switch back to main thread to update UI and insert image
let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText)
attachments.forEach({ (attachment) in
combination.insert(NSAttributedString(string: "\n"), at: range)
combination.insert(NSAttributedString(attachment: attachment), at: range)
combination.insert(NSAttributedString(string: "\n"), at: range)
})
self.contentTextView.attributedText = combination
}
// Upload image to server
// Alamofire post or....
// POST image
// if failed {
if let content = self.contentTextView.attributedText {
content.enumerateAttributes(in: NSMakeRange(0, content.length), options: NSAttributedString.EnumerationOptions(rawValue: 0)) { (object, range, stop) in
if object.keys.contains(NSAttributedStringKey.attachment) {
if let attachment = object[NSAttributedStringKey.attachment] as? UploadImageNSTextAttachment,attachment.uuid == obj.key {
// REPLACE:
attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
attachment.image = // ERROR Image
let combination = NSMutableAttributedString(attributedString: content)
combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
// OR DELETE:
// combination.deleteCharacters(in: range)
self.contentTextView.attributedText = combination
}
}
}
}
//}
//
}
}
}
}
By this point, most problems were solved, so what troubled me for two weeks?
Answer: “Memory” issue
iPhone 6 can’t hold up anymore!
When inserting more than 5 images using the above method, UITextView will start to lag; beyond a certain point, the app will crash due to memory overload.
p.s Tried various compression/other storage methods, but the result remains the same
The suspected reason is that UITextView does not reuse NSTextAttachments for images, so all the images you insert are loaded into memory and not released; therefore, unless they are small icons like emojis 😅, they cannot be used for text wrapping.
Chapter 2
After discovering this “hardware flaw” in the memory, I continued searching online for solutions and found the following methods:
Using WebView to embed an HTML file (with <div contentEditable="true"></div>) and interacting between JS and WebView
Using UITableView with UITextView, enabling reuse
Extending UITextView Based on TextKit 🏆
The first method uses WebView to embed an HTML file; considering performance and user experience, it is not considered. Interested readers can search for related solutions on GitHub (e.g., RichTextDemo).
Item 2: Using UITableView with UITextView
I have implemented about 70% of it. Specifically, each line corresponds to a cell. There are two types of cells: one with a UITextView and one with a UIImageView. The layout alternates between an image line and a text line. The content must be stored in an array to prevent loss during reuse.
Efficient reuse can solve memory issues, but eventually, it was abandoned. The parts where pressing Return at the end of a line should create a new line and move the cursor there and where pressing Backspace at the beginning of a line should move to the previous line (and delete the current line if it is empty) were particularly challenging and hard to control.
For those interested, please refer to: MMRichTextEdit
Final Chapter
Having spent a lot of time reaching this point, the development schedule is seriously delayed; the current final solution is to use TextKit.
Here are two articles for friends interested in researching:
But there is a certain learning curve, which is too difficult for a newbie like me. Besides, I don’t have enough time, so I can only aimlessly search on Github to borrow ideas from others.
Finally found XLYTextKitExtension, a project that can be directly imported and used in code.
✔ Enable NSTextAttachment to support custom UIView with any interactive operations needed
✔ NSTextAttachment can be reused without causing memory bloat
The specific implementation is similar to Chapter 1, except that NSTextAttachment is replaced with XLYTextAttachment.
For the UITextView to be used:
1
contentTextView.setUseXLYLayoutManager()
Tip 1: Change the place where NSTextAttachment is inserted to
1
2
3
4
5
6
7
8
9
let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: ""))
let imageView = UIView() // your custom view
let imageAttachment = XLYTextAttachment { () -> UIView in
return imageView
}
imageAttachment.id = id
imageAttachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
combine.append(NSAttributedString(attachment: imageAttachment))
self.contentTextView.textStorage.insert(combine, at: range)
Tip 2: Change NSTextAttachment search to
1
2
3
4
5
self.contentTextView.textStorage.enumerateAttribute(NSAttributedStringKey.attachment, in: NSRange(location: 0, length: self.contentTextView.textStorage.length), options: []) { (value, range, stop) in
if let attachment = value as? XLYTextAttachment {
//attachment.id
}
}
Tip 3: Delete the NSTextAttachment item and replace it with
1
self.contentTextView.textStorage.deleteCharacters(in: range)
Tip 4: Get the Current Content Length
1
self.contentTextView.textStorage.length
Tip 5: Refresh the Bounds Size of the Attachment
The main reason is for user experience; when inserting an image, I first insert a loading image. The inserted image is replaced only after it is compressed in the background. The TextAttachment’s bounds need to be updated to the resized dimensions.
1
self.contentTextView.textStorage.addAttributes([:], range: range)
(Add empty attribute to trigger refresh)
Tip 6: Convert Input Content into Transmittable Text
Use Tip 2 to search all input content and extract the Attachment IDs, then combine them into a format like [[ID]] for passing.
Tip 7: Content Replacement
1
self.contentTextView.textStorage.replaceCharacters(in: range, with: NSAttributedString(attachment: newImageAttachment))
Tip 8: Use Regular Expressions to Match the Range of Content
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let pattern = "(\\[\\[image_id=){1}([0-9]+){1}(\\]\\]){1}"
let textStorage = self.contentTextView.textStorage
if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
while true {
let range = NSRange(location: 0, length: textStorage.length)
if let match = regex.matches(in: textStorage.string, options: .withTransparentBounds, range: range).first {
let matchString = textStorage.attributedSubstring(from: match.range)
//FOUND!
} else {
break
}
}
}
Note: When using Find & Replace, you must use a While loop. Otherwise, if there are multiple search results, replacing the first one will cause the range of subsequent results to be incorrect, leading to a crash.
Conclusion
The product has been completed and launched using this method, and no issues have been encountered so far; I will take the time to thoroughly explore the underlying principles later!
This article is more of a personal problem-solving experience rather than a tutorial; if you are working on similar features, I hope it helps you. Feel free to contact me with any questions or feedback.
The Official First Post on Medium
Further Reading
For any questions or feedback, feel free to contact me.
This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.