1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

1. はじめに

本記事の目的と背景

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

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

記事の前提条件

  • 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をつけて、テキストの描画領域を±0にします。
  • 複数回 drawGlyphs は呼ばれるので glyphsToShow のレンジをみて、最初のチャンクなのかどうかを確認します。
  • xibからinitするときに呼ばれる init?(coder _: NSCoder) ですが、TextContainer等の設定をうまく行うことができず使用できないように制限をかけました。

4. 実装例

実装例のコード解説

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

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
    }
}
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. 参考文献

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?