ZhgChg.Li

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

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

iOS UITextView 文繞圖編集:Swiftで自在にテキストと画像を配置する方法|効率的なUI設計
本記事は AI による翻訳です。お気づきの点があればお知らせください。

iOS UITextView 文字回り画像エディター (Swift)

実践ルート

目標機能:

APPには、ユーザーが記事を投稿できる掲示板機能があります。記事投稿画面では、文字入力、多数の画像挿入、そして文字と画像が入り混じったレイアウトに対応する必要があります。

機能要件:

  • 複数行のテキストを入力できます

  • 行内に画像を挿入できます

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

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

  • 画像アップロードの効果/失敗処理

  • 入力内容を送信可能なテキストに変換する例:BBコード

まず完成品のスクリーンショット:

結婚吧APP

結婚吧APP

開始:

第一章

何?第一章と言った?UITextViewを使えばエディター機能はできるから、「章」に分ける必要なんてないだろう;はい、私も最初はそう思っていました。しかし、実際に作り始めてみると、そんなに簡単ではないことに気づきました。2週間も悩み、国内外の資料を読み漁った末にようやく解決策を見つけました。その実装の過程をじっくりお話しします…。

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

はじめに

文字編集は当然UITextViewコンポーネントを使用します。ドキュメントを見ると、UITextViewのattributedTextはNSTextAttachmentオブジェクトを持っており、画像を添付して文字回り込みの効果を実装できます。コードも非常にシンプルです:

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でリサイズしてください。

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

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を拡張するために別のクラスを作成し、識別用の値を保持するプロパティを追加しました。
class UploadImageNSTextAttachment:NSTextAttachment {
   var uuid:String? // UUIDを保持するためのプロパティ
}

アップロード時に画像を以下のように変更:

let id = UUID().uuidString
let attachment = UploadImageNSTextAttachment()
attachment.uuid = id

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

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
            }
        }
    }
}

上述の問題を克服した後、コードはおおよそ以下のようになります:

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に対してReuseを行っていないため、挿入したすべての画像がメモリにロードされて解放されないことです。ですので、絵文字のような小さな画像😅を挿入する場合を除き、文中に画像を挿入して回り込ませる用途には使えません。

第二章

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

  • 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で、画像と文字が交互に並びます。内容は配列で保存し、Reuse時に消えないようにしています。

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

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

最終章

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

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

しかし、一定の学習ハードルがあり、私のような初心者には難しすぎますし、時間も足りません。仕方なく、GitHubで目的もなく他のプロジェクトを探して参考にしていました。

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

✔ NSTextAttachmentにカスタムUIViewのサポートを追加し、任意のインタラクション操作を可能にする方法

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

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

使用するUITextViewに対して:

contentTextView.setUseXLYLayoutManager()

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

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の検索を次のように変更

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

self.contentTextView.textStorage.deleteCharacters(in: range)

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

self.contentTextView.textStorage.length

Tip 5: AttachmentのBoundsサイズを更新する

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

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

(空のプロパティを追加し、リフレッシュをトリガー)

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

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

Tip 7: コンテンツの置き換え

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

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

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 から変換されました。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント