記事

iOS UITextView 文繞圖編集:Swiftで自在にテキストと画像を配置する方法|効率的なUI設計

iOS開発者向けにUITextViewでの文繞図編集の課題を解決。Swiftを使い、テキストと画像の自由配置を実現し、UI設計の効率を大幅に向上させる具体的手法を解説。

iOS UITextView 文繞圖編集:Swiftで自在にテキストと画像を配置する方法|効率的なUI設計

本記事は AI による翻訳をもとに作成されています。表現が不自然な箇所がありましたら、ぜひコメントでお知らせください。

記事一覧


iOS UITextView 文字回り編集器 (Swift)

実践ルート

目標機能:

APPにはユーザーが記事を投稿できる掲示板機能があります。投稿画面では、文字入力、多数の画像挿入、そしてテキストの回り込み(文繞圖)に対応する必要があります。

機能要件:

  • 複数行のテキストを入力可能

  • 行内に画像を挿入できる

  • 複数の画像をアップロード可能

  • 挿入した画像を自由に削除できる

  • 画像アップロードの成功/失敗処理

  • 入力内容を転送可能なテキストに変換可能 例: BBCODE

まず完成した動作イメージ:

[結婚吧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"}

結婚吧APP

開始:

第一章

何?第一章と言った?UITextViewを使えば編集機能はできるから、「章」に分ける必要なんてないだろうと最初は思いました。はい、私も最初はそう考えていました。しかし、実際に作り始めてみるとそう簡単ではなく、2週間も悩み、国内外の資料を調べ尽くしてようやく解決策を見つけました。その実装までの心の軌跡をじっくりお話しします…。

もし最終的な解決策をすぐに知りたい場合は、最後の章までスクロールしてください(どんどんスクロール)。

はじめに

文字編集は当然UITextViewコンポーネントを使用します。ドキュメントを確認したところ、UITextViewのattributedTextはNSTextAttachmentオブジェクトを内蔵しており、画像を添付して文囲み効果を実現できます。コードも非常に簡単です:

1
2
3
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: "example")
self.contentTextView.attributedText = NSAttributedString(attachment: imageAttachment)

当時は単純に「簡単で便利だな」と思っていましたが、問題はこれから始まったのです:

  • 画像はローカルから選択&アップロード可能にする:これは簡単に解決できます。画像ピッカーには TLPhotoPicker を使用しました(複数画像選択対応/カスタマイズ設定/カメラ切替撮影/Live Photos対応)。具体的な方法は、TLPhotoPickerで画像を選択後のコールバックでPHAssetをUIImageに変換し、imageAttachment.imageにセットし、あらかじめバックグラウンドで画像をサーバーにアップロードします。

  • 画像アップロードに効果を付けて、インタラクティブ操作(クリックで原画表示/Xをクリックで削除)を実装したい:実現できませんでした。NSTextAttachmentでこの機能を実装する方法が見つかりませんでした。ただ、この機能がなくても問題なく、画像の後ろでキーボードの「Back」キーを押せば画像を削除できるので、そのまま続けます…

  • 原画像ファイルが大きすぎて、アップロードや挿入が遅く、パフォーマンスに影響するため、挿入およびアップロード前にKingfisherのresizeToでリサイズしています。

  • 画像をカーソル位置に挿入:ここで元のコードを以下のように変更します

1
2
3
4
let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
let combination = NSMutableAttributedString(attributedString: self.contentTextView.attributedText) // 現在の内容を取得
combination.insert(NSAttributedString(attachment: imageAttachment), at: range)
self.contentTextView.attributedText = combination // 書き戻す
  • 画像アップロード失敗の処理:ここでは、元のNSTextAttachmentを拡張する別のクラスを作成し、識別用の値を格納するプロパティを追加しました。
1
2
3
class UploadImageNSTextAttachment:NSTextAttachment {
   var uuid:String? // 画像のUUIDを保持
}

画像アップロード時に変更:

1
2
3
4
let id = UUID().uuidString
let attachment = UploadImageNSTextAttachment()
// UUIDを設定
attachment.uuid = id

NSTextAttachmentを識別できれば、アップロードに失敗した画像に対してattributedText内でNSTextAttachmentを検索し、それを見つけてエラーメッセージ画像に置き換えたり、直接削除したりすることが可能です。

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 == "目標ID" {
                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))
                // 直接削除する場合はdeleteCharacters(in: range)を使用可能
                self.contentTextView.attributedText = combination
            }
        }
    }
}

上記の問題を克服した後、コードはだいたいこのようになります:

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の画像ピッカーのコールバック
    
    let range = self.contentTextView.selectedRange.location ?? NSRange(location: 0, length: 0)
    //カーソルの位置を取得。なければ先頭から
    
    guard withTLPHAssets.count > 0 else {
        return
    }
    
    DispatchQueue.global().async { in
        //バックグラウンドで処理
        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)
                //リサイズ
                
                let attachment = UploadImageNSTextAttachment()
                attachment.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height)
                attachment.uuid = id
                
                DispatchQueue.main.async {
                    //メインスレッドに戻ってUIを更新し画像を挿入
                    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
                    
                }
                
                //画像をサーバーにアップロード
                //Alamofireのpostなど
                //POST image
                //失敗した場合 {
                    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 {
                                    
                                    //置き換え:
                                    attachment.bounds = CGRect(x: 0, y: 0, width: 30, height: 30)
                                    attachment.image = //エラー画像
                                    let combination = NSMutableAttributedString(attributedString: content)
                                    combination.replaceCharacters(in: range, with: NSAttributedString(attachment: attachment))
                                    //または削除:
                                    //combination.deleteCharacters(in: range)
                                    
                                    self.contentTextView.attributedText = combination
                                }
                            }
                        }
                    }
                //}
                //
                
            }
        }
    }
}

ここまででほとんどの問題は解決しましたが、それはなぜ2週間も私を悩ませたのでしょうか?

答:「メモリ」問題

iPhone 6が耐えられない!

iPhone 6はもう限界だ!

以上の方法で5枚以上の画像を挿入すると、UITextViewがカクつき始めます。ある程度を超えると、メモリ負荷によりアプリが直接クラッシュします。

p.s さまざまな圧縮や他の保存方法を試しましたが、結果は変わりませんでした

推測される原因は、UITextViewが画像のNSTextAttachmentを再利用していないため、挿入したすべての画像がメモリ内に読み込まれたまま解放されないことです。そのため、絵文字のような小さな画像以外は、文囲み画像として使うことはほぼ不可能です😅。

第二章

メモリという「致命的な問題」を発見した後、ネットで解決策を検索し、以下の他の方法を見つけました:

  • WebViewにHTMLファイル(<div contentEditable=”true”></div>)を埋め込み、JSとWebViewでインタラクションを行う

  • UITableViewとUITextViewを組み合わせて、再利用可能にする

  • TextKitをベースにUITextViewを独自拡張🏆

第一の方法はWebViewでHTMLファイルを埋め込むやり方です。パフォーマンスとユーザー体験を考慮して採用しませんでした。興味がある方はGithubで関連する解決策を検索してください(例: RichTextDemo)。

第二項はUITableViewとUITextViewを組み合わせる方法

私は約7割ほど実装しました。具体的には、各行が1つのセルで、セルには2種類あります。1つはUITextView、もう1つはUIImageViewで、画像と文字が交互に1行ずつ並びます。内容は配列で保存し、Reuse時に消えないようにしています。

優れたReuseでメモリ問題は解決できましたが、最終的には断念しました。行末でReturnキーを押すと新しい行を作成してその行に移動することと、行頭でBackキーを押すと前の行に移動し(現在の行が空行ならその行を削除する)この2つの部分で非常に苦労し、制御が非常に難しかったです。

興味のある方はこちらをご参照ください: MMRichTextEdit

最終章

ここまで来るのに多くの時間を費やし、開発スケジュールが大幅に遅延しました;現在の最終解決策はTextKitを使うことです。

ここに興味のある方のために見つけた記事を2つ添付します:

しかし、ある程度の学習コストがあり、私のような初心者には難しすぎます。さらに時間も足りないので、ただ無目的にGithubで他のプロジェクトを探して参考にするしかありません。

最終的に XLYTextKitExtension というプロジェクトを見つけて、コードを直接導入して使用できました。

✔ NSTextAttachmentにカスタムUIViewを対応させ、必要なインタラクションをすべて追加可能にする

✔ NSTextAttachment は再利用でき、メモリを圧迫しません

具体的な実装方法は第一章とほぼ同じで、違いは元々NSTextAttachmentを使っていたところをXLYTextAttachmentに変更した点だけです。

使用するUITextViewについて:

1
contentTextView.setUseXLYLayoutManager()

Tip 1: NSTextAttachmentを挿入する場所を次のように変更する

1
2
3
4
5
6
7
8
9
let combine = NSMutableAttributedString(attributedString: NSAttributedString(string: ""))
let imageView = UIView() // カスタムビュー
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:NSTextAttachmentの検索を次のように変更してください

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:NSTextAttachment項目の削除を次のように変更する

1
self.contentTextView.textStorage.deleteCharacters(in: range) // 指定範囲の文字を削除する

Tip 4: 現在のコンテンツの長さを取得する

1
self.contentTextView.textStorage.length

Tip 5: AttachmentのBoundsサイズをリフレッシュする

主な理由はユーザー体験のためです。画像を挿入するとき、まずローディング画像を挿入し、挿入した画像はバックグラウンドで圧縮した後に差し替えます。その際、TextAttachmentのBoundsをリサイズ後のサイズに更新する必要があります。

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

(空の属性を追加し、リフレッシュをトリガー)

Tip 6: 入力内容を転送可能なテキストに変換する

Tip 2を使って全入力内容を検索し、見つかったAttachmentのIDを取り出して[[ID]]形式で組み合わせて渡す

Tip 7: コンテンツの置換

1
self.contentTextView.textStorage.replaceCharacters(in: range,with: NSAttributedString(attachment: newImageAttachment))

Tip 8: 正規表現でマッチした内容のRangeを取得する

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)
            // 見つかりました!
        } else {
            break
        }
    }
}

注意:検索と置換を行う場合は、Whileループを使用してください。複数の検索結果がある場合、最初の結果を置換すると、その後の検索結果の範囲がずれてクラッシュする可能性があります。

結語

現在この方法で完成品を作成し、リリースしましたが、まだ問題は発生していません。時間があれば、改めてその原理をじっくり探究したいと思います!

この文章はチュートリアルではなく、個人的な問題解決の経験共有です。同様の機能を実装している方の参考になれば幸いです。ご質問やご指摘がありましたら、ぜひご連絡ください。

Mediumの正式な最初の記事

関連記事

ご質問やご意見がございましたら、こちらからご連絡ください

PostZMediumToMarkdown によって Medium から変換されました。


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本記事は Medium にて初公開されました(こちらからオリジナル版を確認)。ZMediumToMarkdown による自動変換・同期技術を使用しています。

Improve this page on Github.

本記事は著者により CC BY 4.0 に基づき公開されています。