Home iOS UITextView Text Wrapping Editor (Swift)
Post
Cancel

iOS UITextView Text Wrapping Editor (Swift)

iOS UITextView Text Wrapping Editor (Swift)

Practical Route

Target Functionality:

The app has a discussion area where users can post articles. The interface for posting articles needs to support text input, inserting multiple images, and text wrapping with images.

Functional Requirements:

  • Ability to input multiple lines of text
  • Ability to insert images within the text
  • Ability to upload multiple images
  • Ability to freely remove inserted images
  • Image upload effects/failure handling
  • Ability to translate input content into a transmittable text format, e.g., BBCODE

Here’s a preview of the final product:

[Wedding App](https://itunes.apple.com/tw/app/%E7%B5%90%E5%A9%9A%E5%90%A7-%E4%B8%8D%E6%89%BE%E6%9C%80%E8%B2%B4-%E5%8F%AA%E6%89%BE%E6%9C%80%E5%B0%8D/id1356057329?ls=1&mt=8){:target="_blank"}

Wedding App

Let’s Start:

Chapter One

What? You say Chapter One? Isn’t it just using UITextView to achieve the editor functionality, why does it need to be divided into “chapters”? Yes, that was my initial reaction too, until I started working on it and realized it wasn’t that simple. It troubled me for two weeks, searching through various resources both domestic and international before finding a solution. Let me narrate my journey…

If you want to know the final solution directly, please skip to the last chapter (scroll down down down down).

At the Beginning

Of course, the text editor uses the UITextView component. Looking at the documentation, UITextView’s attributedText comes with an NSTextAttachment object 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 quite happy thinking it was simple and convenient; but the problems were just beginning:

  • Images need to be selectable & uploadable from local storage: This is easy to solve. For image selection, I used the TLPhotoPicker library (supports multiple image selection/custom settings/switching to camera mode/Live Photos). The specific approach is to convert PHAsset to UIImage after TLPhotoPicker’s callback and insert it into imageAttachment.image, then upload the image to the server in the background.
  • Image upload needs to have effects and interactive operations (click to view the original image/click X to delete): Couldn’t achieve this, couldn’t find a way to do this with NSTextAttachment. However, it’s still possible to delete the image (press the “Back” key on the keyboard after the image to delete it), so let’s continue…
  • Original image files are too large, slow to upload, slow to insert, and consume performance: Resize before inserting and uploading, using Kingfisher’s resizeTo.
  • Insert images at the cursor position: Here, the original code needs to be modified 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 need to mention that I actually wrote another class to extend the original NSTextAttachment, with the purpose of adding an attribute to store an identifier value.
1
2
3
class UploadImageNSTextAttachment:NSTextAttachment {
   var uuid:String?
}

When uploading an image, change to:

1
2
3
let id = UUID().uuidString
let attachment = UploadImageNSTextAttachment()
attachment.uuid = id

Once we can identify the corresponding NSTextAttachment, we can search for the NSTextAttachment in the attributedText for the failed upload image, find it, and replace it with an error icon or remove it 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 problem, the code will look 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]) {
    // TLPhotoPicker image picker callback
    
    let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
    // Get the cursor position, if none, start from the beginning
    
    guard withTLPHAssets.count > 0 else {
        return
    }
    
    DispatchQueue.global().async { in
        // Process in the 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 the main thread to update the UI and insert the 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 now, most of the issues have been resolved. So, what troubled me for two weeks?

Answer: “Memory” issues

iPhone 6 can't handle it!

iPhone 6 can’t handle it!

When inserting more than 5 images using the above method, UITextView starts to lag; at a certain point, the app crashes due to memory overload.

p.s. Tried various compression/other storage methods, but the result was the same.

The suspected reason is that UITextView does not reuse NSTextAttachment for images, so all inserted images are loaded into memory and not released. Unless you’re inserting small images like emojis 😅, you can’t use it for text wrapping around images.

Chapter 2

After discovering this “hard” memory issue, I continued searching online for solutions and found the following alternatives:

  • Use WebView to embed an HTML file (<div contentEditable="true"></div>) and interact with WebView using JS.
  • Use UITableView combined with UITextView for reuse.
  • Extend UITextView based on TextKit 🏆

The first method of embedding an HTML file in WebView was not considered due to performance and user experience concerns. Interested friends can search for related solutions on GitHub (e.g., RichTextDemo).

The second method of using UITableView combined with UITextView:

I implemented about 70% of it. Specifically, each line is a Cell, with two types of Cells: one for UITextView and one for UIImageView, with one line for text and one line for images. The content must be stored in an array to avoid disappearing during reuse.

This method excellently solves the memory issue through reuse, but I eventually gave up due to the difficulty in controlling creating a new line and jumping to it when pressing Return at the end of a line and jumping to the previous line when pressing Backspace at the beginning of a line (and deleting the current line if it’s empty). These parts were very hard to control.

Interested friends can refer to: MMRichTextEdit.

Final Chapter

By this point, a lot of time had been spent, and the development schedule was severely delayed. The final solution was to use TextKit.

Here are two articles for friends interested in researching further:

However, there is a certain learning curve, which was too difficult for a novice like me. Moreover, time was running out, so I aimlessly searched GitHub for solutions.

Finally, I found XLYTextKitExtension, which can be directly imported and used.

✔ Allows NSTextAttachment to support custom UIViews, enabling any interactive operations.

✔ NSTextAttachment can be reused without exhausting memory.

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 insertion of NSTextAttachment 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: Search for NSTextAttachment and replace with

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 NSTextAttachment item and replace 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 will first insert a loading image, and the inserted image will be replaced after being compressed in the background. The Bounds of the TextAttachment need to be updated to the resized size.

1
self.contentTextView.textStorage.addAttributes([:], range: range)

(Add empty attributes to trigger refresh)

Tip 6: Convert input content into transmittable text

Use Tip 2 to search all input content and extract the IDs of the found Attachments, combining them into a format like [ [ID] ] for transmission.

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)
            //FINDED!
        } else {
            break
        }
    }
}

Note: If you need to search & replace items, you need to use a While loop. Otherwise, when there are multiple search results, after finding and replacing the first one, the range of the subsequent search results will be incorrect, causing a crash.

Conclusion

Currently, I have completed the product using this method and it is online without any issues; I will explore the principles behind it when I have time!

This article is more of a personal problem-solving experience sharing rather than a tutorial; if you are implementing similar functionality, I hope it helps you. Feel free to contact me with any questions or feedback.

The first official post on Medium

Further Reading

Feel free to contact me with any questions or feedback.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here


This post is licensed under CC BY 4.0 by the author.

First Post on Medium

iOS ≥ 10 Notification Service Extension Application (Swift)