UIKit NSTextAttachmentContainer プロトコル
#UIKit の NSTextAttachmentContainer を利用して、NSTextAttachment からサーバーへの画像取得を行なうコードを説明します。 NSTextAttachmentContainer 概略
NSTextAttachmentContainer は NSLayoutManager から呼ばれる NSTextAttachment を操作する Protocol です。
2 つのメソッドを定義しています。
attachmentBounds(for:proposedLineFragment:glyphPosition:characterIndex:)
レイアウト時に呼ばれて、NSLayoutManager に NSTextAttachment のサイズを教えます。
image(forBounds:textContainer:characterIndex:)
レイアウト時に呼ばれて、NSLayoutManager に NSTextContainer 内で描画するための画像を渡します。
メリット
UITextView.draw などの処理で画像取得をするより、NSTextAttachment に処理がまとまるので、コードのスコープが小さくなります。
Attachment の種類によって処理を変えたい場合でも、種類ごとに NSAttachment のサブクラスを作れば OK です。
NSLayoutManager からレイアウト回りの情報が渡されるので、レイアウトの微調性が可能です。
前提・仕様
サムネイルを UITextView に表示する。
サムネイルは NSTextAttachment をサブクラス化した ThumbnailAttachment で表示する。
Thumbnail.ID でサーバーから画像を取得できる。
ThumbnailService.fetch(by:completion:) を使います。
サーバーからサムネイル取得前は何も表示しない。
サムネイルの高さは固定。
サムネイルの横幅は画像取得後にアスペクト比に応じて変更。
サーバーからサムネイル取得に失敗したら Thumbnail.empty を表示。
正常系の流れ
1. NSLayoutManager が attachmentBounds(...) を呼び出して画像サイズを取得しようとします。
サムネイル取得前なので ThumbnailAttachment.originalSize を返します。
code:swift
final class ThumbnailAttachment: NSTextAttachment {
override func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
CGRect(origin: .zero,
size: imageSize ?? originalSize)
}
}
2. NSLayoutManager が image(...) を呼び出して画像を取得しようとします。
キャッシュはないのでサーバーにサムネイル取得をリクエストします。
ここでは nil を返します。
code:swift
final class ThumbnailAttachment: NSTextAttachment {
override func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? {
guard let cached = image else {
guard let contentsImage = contents.map(UIImage.init) else {
self.textContainer = textContainer
startAsyncImageDownload(textContainer: textContainer)
return nil
}
return contentsImage
}
return cached
}
private func startAsyncImageDownload(textContainer: NSTextContainer?) {
ThumbnailService.shared.fetch(by: thumbnailId) { result in ... }
}
}
3. サーバーからサムネイルを取得
imageSize と image をセットします。
メインスレッドで NSLayoutManager に再レイアウト、再描画を依頼します。
code:swift
final class ThumbnailAttachment: NSTextAttachment {
private func startAsyncImageDownload(textContainer: NSTextContainer?) {
ThumbnailService.shared.fetch(by: thumbnailId) { result in
switch result {
case let .success(image):
self.imageSize = { image, height in
let aspectRatio: CGFloat = image.size.width / image.size.height
return CGSize(width: height * aspectRatio, height: height)
}(image, self.originalSize.height)
self.image = image
case .failure: ...
}
DispatchQueue.main.async {
guard let layoutManager = textContainer?.layoutManager else { return }
layoutManager.ranges(for: self)?.reversed().forEach { range in
layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)
layoutManager.invalidateDisplay(forCharacterRange: range)
}
}
}
}
}
全サンプル・コード
code:swift
final class ThumbnailAttachment: NSTextAttachment {
let thumbnailId: Thumbnail.ID
/// Attachment をレイアウトしようとしている NSTextContainer; layoutManager からレイアウト時に渡される。
private weak var textContainer: NSTextContainer?
/// Attachment に画像をセットする前のサイズ
private let originalSize: CGSize
/// Attachment に画像をセットした後のサイズ; 画像のサイズと同じ
private var imageSize: CGSize?
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
init(thumbnailId: Thumbnail.ID, bounds: CGRect) {
self.thumbnailId = thumbnailId
originalSize = bounds.size
super.init(data: nil, ofType: nil)
}
// MARK: NSTextAttachmentContainer
override func attachmentBounds(for textContainer: NSTextContainer?, proposedLineFragment lineFrag: CGRect, glyphPosition position: CGPoint, characterIndex charIndex: Int) -> CGRect {
CGRect(origin: .zero,
size: imageSize ?? originalSize)
}
override func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? {
guard let cached = image else {
guard let contentsImage = contents.map(UIImage.init) else {
self.textContainer = textContainer
startAsyncImageDownload(textContainer: textContainer)
return nil
}
return contentsImage
}
return cached
}
private func startAsyncImageDownload(textContainer: NSTextContainer?) {
ThumbnailService.shared.fetch(by: thumbnailId) { result in
switch result {
case let .success(image):
self.imageSize = { image, height in
let aspectRatio: CGFloat = image.size.width / image.size.height
return CGSize(width: height * aspectRatio, height: height)
}(image, self.originalSize.height)
self.image = image
case .failure:
self.imageSize = self.originalSize
self.image = nil
}
DispatchQueue.main.async {
guard let layoutManager = textContainer?.layoutManager else { return }
layoutManager.ranges(for: self)?.reversed().forEach { range in
layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)
layoutManager.invalidateDisplay(forCharacterRange: range)
}
}
}
}
}
struct Thumbnail {
static let empty: UIImage = UIImage()
struct ID: RawRepresentable {
let rawValue: String
}
let id: ID
}
struct ThumbnailService {
static let shared = ThumbnailService()
func fetch(by id: Thumbnail.ID, completion: @escaping (Result<UIImage, Error>) -> Void) {
completion(.success(UIImage()))
}
}
private extension NSLayoutManager {
func ranges(for thumbnail: ThumbnailAttachment) -> NSRange? { guard let attributedString = textStorage else { return nil }
attributedString.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributedString.length)) { value, range, _ in
guard let thumbnailValue = value as? ThumbnailAttachment else { return }
if thumbnail.thumbnailId == thumbnailValue.thumbnailId {
ranges.append(range)
}
}
return ranges
}
}