Text view adventures, Part 3

December 12, 2018

By Jeff Johnson

Part 1 and Part 2 of my text view adventures were about NSTextView for Mac. Part 3 here and Part 4 in the future will be about UITextView for iOS. In Part 2 I wrote about how to handle pasting of arbitrary file types, including movies, into NSTextView. You might wonder how UITextView handles this. The answer is… not very well. On iOS 12, "Copy" is not even listed among the actions when you share a movie from Photos app. However, you can "Save to Files", and Files app will allow you to copy a movie file. If you want to allow users to save files "On My iPhone" in Files app, you need to add LSSupportsOpeningDocumentsInPlace and UIFileSharingEnabled to your app's Info.plist file.

In a text view, a file attachment is represented as an NSAttributedString with a special NSAttachmentCharacter string and a NSAttachmentAttributeName attribute. This is what you get when you paste an image file, for example. Unfortunately, UITextView does not support pasting arbitrary file types. It doesn't show movies inline like NSTextView does. (It doesn't even animate gifs! But that's a story for another day.) When you paste a movie file, you get an NSAttributedString with an empty string and no attributes, which is totally useless. If you know in advance which file types you want to accept, then you can probably configure the UITextView to accept them, but what if you want to accept, you know, anything?

Some reverse engineering revealed that paste in UITextView is handled by UITextInputController. The empty attributed string for the movie file comes from -[UITextInputController _insertAttributedText:fromKeyboard:]. I wouldn't be allowed to hack this in the App Store, though, since it's all private system implementation and not public API. To work around the problem, I needed to use supported methods. I couldn't find a good way to reliably identify the paste failure using the UITextView delegate methods, not even NSTextStorageDelegate. Thus, I decided to subclass UITextView and override -[UITextView paste:].

Even in -[UITextView paste:], you can't really determine when super paste: fails, and I didn't want to reimplement paste entirely myself, so I decided to let UITextView handle the types it knows about, and I would handle the rest. Here's some code:

@interface JJPasteTextView : UITextView @end
@implementation JJPasteTextView -(void)paste:(id)sender {
UIPasteboard *generalPasteboard = [UIPasteboard generalPasteboard];
if (![generalPasteboard hasStrings] && ![generalPasteboard hasImages] && ![generalPasteboard hasURLs]) {
  NSArray<NSString *> *pasteboardTypes = [generalPasteboard pasteboardTypes];
  if ([pasteboardTypes count] > 0) {
    NSString *pasteboardType = pasteboardTypes[0];
    NSData *data = [generalPasteboard dataForPasteboardType:pasteboardType];
    if (data != nil && [data length] > 0) {
      NSFileWrapper *fileWrapper = [[NSFileWrapper alloc] initRegularFileWithContents:data];
      if (fileWrapper != nil) {
        CFStringRef extension = UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)pasteboardType, kUTTagClassFilenameExtension);
        if (extension != NULL) {
          [fileWrapper setPreferredFilename:[@"Pasted File." stringByAppendingString:CFBridgingRelease(extension)]];
        }
        NSTextAttachment *attachment = [[NSTextAttachment alloc] initWithData:nil ofType:nil];
        [attachment setFileWrapper:fileWrapper];
        NSAttributedString *attachmentString = [NSAttributedString attributedStringWithAttachment:attachment];
        if (attachmentString != nil) {
          NSRange oldSelectedRange = [self selectedRange];
          // Put the new selection right after the attachment.
          NSRange newSelectedRange = NSMakeRange(oldSelectedRange.location + 1, 0);
          [[self textStorage] replaceCharactersInRange:oldSelectedRange withAttributedString:attachmentString];
          [self setSelectedRange:newSelectedRange];
          [[self delegate] textViewDidChange:self];
          return;
        }
      }
    }
  }
}
[super paste:sender]; } @end

iOS will show document icons for file types that it recognizes, or a generic icon for file types it doesn't recognize. You may want to customize the icon of the file attachment, because iOS doesn't recognize many file types. You can customize the icon by subclassing NSTextAttachment and overriding -[NSTextAttachment image]. Usually this method returns nil when the NSTextAttachment has a fileWrapper, in which case UITextView falls back to the file extension to select the appropriate icon, but a custom UIImage returned by -[NSTextAttachment image] will take precedence.

That's the end of Part 3. Part 4 will discuss everyone's favorite topic: UITextView scrolling.