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