UITextViewでn行目以降にblurをかける

# 1. はじめに

## 本記事の目的と背景

有料会員限定の記事などで、１行目だけはユーザーに見えるようにして、それ以降の行はぼかしたいというケースがあると思います。
そのようなケースに役立つ実装を紹介します。


・実行結果のスクリーンショット
UITextViewのbackgroundColorを設定しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197742/a7f1a439-c334-4f28-31bc-25d87baa3494.png" width="300">

**記事の前提条件**

- iOS 13+
- Xcode 14.2
- Swift 5.7.2
- UIKit

# 2. blur効果の基礎知識

## 検討した手法1
iOSではblurをかける際、基本的にはUIVisualEffectViewを使用することになると思います。
しかし、このクラスを使用する場合はBlurの強度を調整することは基本的にできません。
UIViewPropertyAnimatorを使用した強度調整のワークアラウンドもありますが、画面遷移などで動作が不安定になってしまうケースがあったので見送りました。

## 検討した手法2
そこでSwiftUIのblur機能を用いて、UIKitのViewの上にSwiftUIのViewを配置してblurをかける手法を考えたのですが、
blurはSwiftUIのviewにしかかからず、効果は得られませんでした。

## 今回採用した方法

CIImage経由だとCIFilterで任意のフィルターをかけることができ、BlurEffectなどの種類も豊富なのでこのやり方にすることにしました。

# 3. UITextViewでn行目以降にblurをかける方法

## 具体的な実装方法
UITextViewにはNSTextContainerというテキストの描画領域を管理するクラスがあり、
NSTextContainerにはNSLayoutManagerというテキストの描画を行うクラスがあります。

NSLayoutManager内の　`drawGlyphs` 関数をオーバーライドすることで、独自のテキストを描画できます。
Viewを重ねるような実装ではないので、通常通りテキストの選択もできます。（isEditableなど通常通り機能します）

## 実装時の注意点

- UITextViewにそのままBlurをかけたテキストの画像を載せてしまうと、領域の端でBlurが切れてしまいます。
    - → UITextViewは実際に表示したい領域よりもPaddingをつけて大きくレイアウトする必要があります。
    - → textContainerInsetをつけて、テキストの描画領域を±０にします。
- 複数回 `drawGlyphs`　は呼ばれるので `glyphsToShow` のレンジをみて、最初のチャンクなのかどうかを確認します。
- xibからinitするときに呼ばれる `init?(coder _: NSCoder)` ですが、TextContainer等の設定をうまく行うことができず使用できないように制限をかけました。

# 4. 実装例

実装例のコード解説

isContentRestrictedにfalseを渡してあげると、blurの閲覧制限をかけることができます。

```swift
import UIKit

public final class BlurredTextView: UITextView {
    public enum Const {
        /// Blur時に切れないように、4ptのinsetを設け、xib側では4ptのマージンを設ける
        public static let textContainerInset = UIEdgeInsets(all: 4)
        public static let minimumHeightForRestriction: CGFloat = 162
    }

    public var isContentRestricted: Bool {
        get { layoutManager.isContentRestricted }
        set { layoutManager.isContentRestricted = newValue }
    }

    override public var layoutManager: BlurredGlyphLayoutManager {
        _layoutManager
    }

    private let _layoutManager = BlurredGlyphLayoutManager()

    public init() {
        let textContainer = NSTextContainer()
        let textStorage = NSTextStorage()
        _layoutManager.addTextContainer(textContainer)
        textStorage.addLayoutManager(_layoutManager)
        super.init(frame: .zero, textContainer: textContainer)
        textContainer.heightTracksTextView = true
        configure()
    }

    @available(*, unavailable)
    override public init(frame _: CGRect, textContainer _: NSTextContainer?) {
        fatalError("init(frame:textContainer:) has not been implemented")
    }

    @available(*, unavailable)
    public required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func configure() {
        backgroundColor = nil
        isScrollEnabled = false
        textContainerInset = Const.textContainerInset
        textContainer.lineFragmentPadding = .zero
    }
}
```


```swift
import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit

public final class BlurredGlyphLayoutManager: NSLayoutManager {
    public enum Const {
        public static let blurRadius: Float = 11
        public static let visibleLineCount: CGFloat = 1
    }

    public var isContentRestricted = false {
        didSet {
            guard let container = textContainers.first else { return }
            invalidateDisplay(forGlyphRange: glyphRange(for: container))
        }
    }

    private var ciContext: CIContext?

    /// テキストの量に応じて複数回呼ばれる。originはtextContainerInsetに依存する。
    override public func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: CGPoint) {
        guard isContentRestricted,
              let textContainer = textContainers.first else {
                  return super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
        }

        if ciContext.isNone {
            ciContext = UIGraphicsGetCurrentContext().map { CIContext(cgContext: $0) }
        }

        UIGraphicsBeginImageContextWithOptions(CGSize(
            width: textContainer.size.width + origin.x * 2,
            height: textContainer.size.height + origin.y * 2
        ), false, UIScreen.main.scale)
        super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
        let textImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        guard let textImage else {
            return super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
        }

        let isFirstGlyphs = glyphsToShow.location == .zero
        drawText(image: textImage, at: origin, visibleFirstLine: isFirstGlyphs)
    }

    private func drawText(image: UIImage, at origin: CGPoint, visibleFirstLine: Bool) {
        guard let textCIImage = CIImage(image: image) else { return }

        let filter: CIFilter

        if visibleFirstLine {
            let firstLineRect = lineFragmentUsedRect(forGlyphAt: .zero, effectiveRange: nil)
            let maskImage = UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: image.size))
                .image { context in
                    context.cgContext.setFillColor(UIColor.white.cgColor)
                    context.cgContext.fill(CGRect(
                        x: .zero,
                        y: firstLineRect.height * Const.visibleLineCount + origin.y + 4,
                        width: image.size.width,
                        height: image.size.height - firstLineRect.height * Const.visibleLineCount
                    ))
                }

            let _filter = CIFilter.maskedVariableBlur()
            _filter.inputImage = textCIImage
            _filter.mask = CIImage(image: maskImage)
            _filter.radius = Const.blurRadius
            filter = _filter
        }
        else {
            let _filter = CIFilter.gaussianBlur()
            _filter.inputImage = textCIImage
            _filter.radius = Const.blurRadius
            filter = _filter
        }

        guard let ciImage = filter.outputImage?
                .cropped(to: textCIImage.extent)
                .resize(to: textCIImage.extent.size) else { return }

        render(ciImage: ciImage, origin: origin)
    }

    private func render(ciImage: CIImage, origin: CGPoint) {
        guard let ciContext,
              let textContainer = textContainers.first else { return }
        let size = CGSize(
            width: textContainer.size.width + origin.x * 2,
            height: textContainer.size.height + origin.y * 2
        )
        guard let cgImage = ciContext.createCGImage(ciImage, from: ciImage.extent) else { return }
        UIImage(cgImage: cgImage)
            .draw(in: CGRect(origin: .zero, size: size))
    }
}

private extension CIImage {
    func resize(to size: CGSize) -> CIImage {
        let selfSize = extent.size
        let transform = CGAffineTransform(
            scaleX: size.width / selfSize.width,
            y: size.height / selfSize.height
        )
        return transformed(by: transform)
    }
}
```

# 6. 参考文献

- https://developer.apple.com/documentation/uikit/nslayoutmanager
- https://thinkit.co.jp/story/2014/10/14/5206
- https://developer.apple.com/library/archive/documentation/StringsTextFonts/Conceptual/TextAndWebiPhoneOS/CustomTextProcessing/CustomTextProcessing.html#//apple_ref/doc/uid/TP40009542-CH4-SW1
- https://stackoverflow.com/questions/49238595/how-to-reduce-blur-effect-on-uivisualeffectview
- https://stackoverflow.com/questions/38520757/fix-uivisualeffectview-extra-light-blur-being-gray-on-white-background/60150060#60150060
- https://developer.apple.com/documentation/uikit/textkit/display_text_with_a_custom_layout