|
| 1 | +// |
| 2 | +// MMMCommonUI. Part of MMMTemple. |
| 3 | +// Copyright (C) 2016-2025 Monks. All rights reserved. |
| 4 | +// |
| 5 | + |
| 6 | +/// A view that allows mixing attributed text with custom views, something that is handy when you want to use a custom |
| 7 | +/// icon in your text, a special link, or just decorate a word in a way that is not supported by CoreText directly. |
| 8 | +/// |
| 9 | +/// How it works: |
| 10 | +/// - You set subviews that should participate in the layout via ``setSubviews(_:)``. |
| 11 | +/// - You set ``text`` where you are referring to those subviews by index adding ``ViewIndexAttribute`` attribute |
| 12 | +/// on placeholder characters. (See ``placeholderForView(_:)`` helper.) |
| 13 | +/// - Sizes and baselines of the referenced subviews are determined using Auto Layout. Unreferenced subviews are hidden. |
| 14 | +/// - The ``text`` is rendered via CoreText with space reserved for the subviews in the corresponding locations. |
| 15 | +/// - The subviews are positioned with Auto Layout constraints against the leftmost points of their baselines. |
| 16 | +public class MMMTextLayout: NonStoryboardableView { |
| 17 | + |
| 18 | + public override init() { |
| 19 | + super.init() |
| 20 | + self.isOpaque = false |
| 21 | + self.contentMode = .redraw |
| 22 | + } |
| 23 | + |
| 24 | + private struct ManagedView { |
| 25 | + var view: UIView |
| 26 | + var boundsGuide: UILayoutGuide |
| 27 | + var ascentGuide: UILayoutGuide |
| 28 | + var leftConstraint: NSLayoutConstraint |
| 29 | + var topConstraint: NSLayoutConstraint |
| 30 | + } |
| 31 | + |
| 32 | + private var managedViews: [ManagedView] = [] |
| 33 | + |
| 34 | + /// The views to layout. |
| 35 | + /// |
| 36 | + /// Only the subviews added via this method can be referenced from ``text`` and participate in the layout. |
| 37 | + /// The receiver controls both the position and the visibility of these views: the ones that are not referenced |
| 38 | + /// from ``text`` or the part of it that is actually visible are going to be automatically hidden. |
| 39 | + public func setSubviews(_ views: [UIView]) { |
| 40 | + for r in managedViews { |
| 41 | + removeLayoutGuide(r.ascentGuide) |
| 42 | + removeLayoutGuide(r.boundsGuide) |
| 43 | + r.view.removeFromSuperview() |
| 44 | + } |
| 45 | + managedViews = views.map { view in |
| 46 | + .init( |
| 47 | + view: view, |
| 48 | + boundsGuide: .init(), |
| 49 | + ascentGuide: .init(), |
| 50 | + // The constants of both of these constraints are updated to position the views. |
| 51 | + leftConstraint: view.leftAnchor.constraint(equalTo: self.leftAnchor), |
| 52 | + // It's convenient to offset from the bottom as layout in CoreText is flipped. |
| 53 | + topConstraint: self.bottomAnchor.constraint(equalTo: view.firstBaselineAnchor) |
| 54 | + ) |
| 55 | + } |
| 56 | + for r in managedViews { |
| 57 | + addSubview(r.view) |
| 58 | + addLayoutGuide(r.boundsGuide) |
| 59 | + addLayoutGuide(r.ascentGuide) |
| 60 | + } |
| 61 | + for r in managedViews { |
| 62 | + NSLayoutConstraint.activate([ |
| 63 | + r.leftConstraint, |
| 64 | + r.topConstraint, |
| 65 | + // One guide will give us width/height of the view without the need to size it separately. |
| 66 | + r.boundsGuide.widthAnchor.constraint(equalTo: r.view.widthAnchor, multiplier: 1), |
| 67 | + r.boundsGuide.heightAnchor.constraint(equalTo: r.view.heightAnchor, multiplier: 1), |
| 68 | + // And another one is needed for the baseline info, i.e. ascent/descent. |
| 69 | + r.ascentGuide.topAnchor.constraint(equalTo: r.view.topAnchor), |
| 70 | + r.ascentGuide.bottomAnchor.constraint(equalTo: r.view.firstBaselineAnchor) |
| 71 | + ]) |
| 72 | + } |
| 73 | + resetFramesetter() |
| 74 | + } |
| 75 | + |
| 76 | + /// The attributed text to render. |
| 77 | + /// |
| 78 | + /// The subviews set via ``setSubviews(_:)`` (and only them) can be referenced in this text by index using |
| 79 | + /// ``ViewIndexAttribute`` attribute on a placeholder character (which is typically a Unicode |
| 80 | + /// Object Replacement Character, `\u{FFFC}`; see ``PlaceholderCharacter``). |
| 81 | + public var text: NSAttributedString = .init(string: "") { |
| 82 | + didSet { |
| 83 | + resetFramesetter() |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + private func runDelegate(_ r: ManagedView) -> CTRunDelegate { |
| 88 | + // The delegate uses C functions, not blocks, so we need to manage some context for it. |
| 89 | + struct Metrics { |
| 90 | + var width, ascent, descent: CGFloat |
| 91 | + } |
| 92 | + let metrics = UnsafeMutablePointer<Metrics>.allocate(capacity: 1) |
| 93 | + metrics.pointee = .init( |
| 94 | + width: r.boundsGuide.layoutFrame.width, |
| 95 | + ascent: r.ascentGuide.layoutFrame.height, |
| 96 | + descent: r.boundsGuide.layoutFrame.height - r.ascentGuide.layoutFrame.height |
| 97 | + ) |
| 98 | + var callbacks = CTRunDelegateCallbacks( |
| 99 | + version: kCTRunDelegateCurrentVersion, |
| 100 | + dealloc: { $0.deallocate() }, |
| 101 | + getAscent: { $0.assumingMemoryBound(to: Metrics.self).pointee.ascent }, |
| 102 | + getDescent: { $0.assumingMemoryBound(to: Metrics.self).pointee.descent }, |
| 103 | + getWidth: { $0.assumingMemoryBound(to: Metrics.self).pointee.width } |
| 104 | + ) |
| 105 | + guard let runDelegate = CTRunDelegateCreate(&callbacks, metrics) else { |
| 106 | + preconditionFailure() |
| 107 | + } |
| 108 | + return runDelegate |
| 109 | + } |
| 110 | + |
| 111 | + private let RecordAttribute = NSAttributedString.Key("MMMTextLayout.ManagedView") |
| 112 | + |
| 113 | + /// The value of this attribute should be an index (`Int`) of the subview to position at the corresponding |
| 114 | + /// location in the text. |
| 115 | + public static let ViewIndexAttribute = NSAttributedString.Key("MMMTextLayout.ViewIndex") |
| 116 | + |
| 117 | + /// The Object Replacement Character character (`\u{FFFC}`) that can be used to mark where a subview needs to be |
| 118 | + /// placed within the ``text``. (You can use other characters, but this one is recommended by CoreText.) |
| 119 | + /// It's not enough to simply insert that character to refer a subview, the index of it should be defined |
| 120 | + /// via ``ViewIndexAttribute`` attribute. |
| 121 | + public static let PlaceholderCharacter = "\u{FFFC}" |
| 122 | + |
| 123 | + /// An attributed string consisting of a single ``PlaceholderCharacter`` with value of |
| 124 | + /// ``ViewIndexAttribute`` on it set to ``index``. |
| 125 | + public static func placeholderForView(_ index: Int) -> NSAttributedString { |
| 126 | + .init(string: Self.PlaceholderCharacter, attributes: [ Self.ViewIndexAttribute: index ]) |
| 127 | + } |
| 128 | + |
| 129 | + private func makeAttributedString() -> NSAttributedString { |
| 130 | + let s = text.mutableCopy() as! NSMutableAttributedString |
| 131 | + s.enumerateAttribute( |
| 132 | + Self.ViewIndexAttribute, |
| 133 | + in: .init(location: 0, length: s.string.count), |
| 134 | + options: .longestEffectiveRangeNotRequired |
| 135 | + ) { value, range, _ in |
| 136 | + guard let value else { |
| 137 | + // We are going to encounter ranges without our attribute. |
| 138 | + return |
| 139 | + } |
| 140 | + guard let index = value as? Int, 0 <= index else { |
| 141 | + assertionFailure("Values of ViewIndexAttribute should be non-negative integers") |
| 142 | + return |
| 143 | + } |
| 144 | + guard index < managedViews.count else { |
| 145 | + // Ignoring unknown views: it could be that the text is set before the views. |
| 146 | + return |
| 147 | + } |
| 148 | + let r = managedViews[index] |
| 149 | + s.addAttributes( |
| 150 | + [ |
| 151 | + (kCTRunDelegateAttributeName as NSAttributedString.Key): runDelegate(r), |
| 152 | + RecordAttribute: r |
| 153 | + ], |
| 154 | + range: range |
| 155 | + ) |
| 156 | + } |
| 157 | + return s |
| 158 | + } |
| 159 | + |
| 160 | + private var framesetter: CTFramesetter? |
| 161 | + |
| 162 | + private func grabFramesetter() -> CTFramesetter { |
| 163 | + if let framesetter { |
| 164 | + return framesetter |
| 165 | + } else { |
| 166 | + let framesetter = CTFramesetterCreateWithAttributedString(makeAttributedString()) |
| 167 | + self.framesetter = framesetter |
| 168 | + return framesetter |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + private func resetFramesetter() { |
| 173 | + self.framesetter = nil |
| 174 | + self.textFrame = nil |
| 175 | + invalidateIntrinsicContentSize() |
| 176 | + setNeedsLayout() |
| 177 | + setNeedsDisplay() |
| 178 | + } |
| 179 | + |
| 180 | + private var textFrame: CTFrame? |
| 181 | + |
| 182 | + private func updateTextFrame() { |
| 183 | + |
| 184 | + self.setNeedsDisplay() |
| 185 | + |
| 186 | + let framesetter = grabFramesetter() |
| 187 | + |
| 188 | + // All views are initially hidden, just in case they are not mentioned in the template or don't fit. |
| 189 | + for r in managedViews { |
| 190 | + r.view.isHidden = true |
| 191 | + } |
| 192 | + |
| 193 | + self.textFrame = CTFramesetterCreateFrame( |
| 194 | + framesetter, |
| 195 | + .init(location: 0, length: 0), // 0 length for the whole string. |
| 196 | + CGPath(rect: bounds, transform: nil), |
| 197 | + nil // No extra attributes. |
| 198 | + ) |
| 199 | + guard let textFrame else { |
| 200 | + // It is possible that the frame is not created when layout is too complex. |
| 201 | + return |
| 202 | + } |
| 203 | + |
| 204 | + // Now we need to find runs corresponding to our placeholders and extract info on their positions. |
| 205 | + // Simply enumerating all runs is OK for now as we don't expect large text. |
| 206 | + let lines = CTFrameGetLines(textFrame) as! [CTLine] |
| 207 | + var lineOrigins: [CGPoint] = .init(repeating: .zero, count: lines.count) |
| 208 | + CTFrameGetLineOrigins(textFrame, .init(location: 0, length: 0), &lineOrigins) |
| 209 | + let origin = CTFrameGetPath(textFrame).boundingBoxOfPath.origin |
| 210 | + for lineAndOrigin in zip(lines, lineOrigins) { |
| 211 | + let (line, lineOrigin) = lineAndOrigin |
| 212 | + for run in CTLineGetGlyphRuns(line) as! [CTRun] { |
| 213 | + let runAttributes = CTRunGetAttributes(run) as! [NSAttributedString.Key: Any] |
| 214 | + guard let record = runAttributes[RecordAttribute] as? ManagedView else { |
| 215 | + continue |
| 216 | + } |
| 217 | + var runOrigin: CGPoint = .zero |
| 218 | + CTRunGetPositions(run, .init(location: 0, length: 1), &runOrigin) |
| 219 | + |
| 220 | + record.leftConstraint.constant = origin.x + lineOrigin.x + runOrigin.x |
| 221 | + record.topConstraint.constant = origin.y + lineOrigin.y + runOrigin.y |
| 222 | + |
| 223 | + // Once the view is referenced and we can show it. |
| 224 | + record.view.isHidden = false |
| 225 | + } |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + private var _intrinsicContentSize: (size: CGSize, boundsWidth: CGFloat)? |
| 230 | + |
| 231 | + public override func invalidateIntrinsicContentSize() { |
| 232 | + _intrinsicContentSize = nil |
| 233 | + super.invalidateIntrinsicContentSize() |
| 234 | + } |
| 235 | + |
| 236 | + public override var intrinsicContentSize: CGSize { |
| 237 | + |
| 238 | + let width = bounds.width |
| 239 | + if let _intrinsicContentSize, _intrinsicContentSize.boundsWidth == width { |
| 240 | + return _intrinsicContentSize.size |
| 241 | + } |
| 242 | + |
| 243 | + let framesetter = grabFramesetter() |
| 244 | + func sizeWithConstraints(_ size: CGSize) -> CGSize { |
| 245 | + CTFramesetterSuggestFrameSizeWithConstraints(framesetter, .init(location: 0, length: 0), nil, size, nil) |
| 246 | + } |
| 247 | + let size = CGSize( |
| 248 | + width: sizeWithConstraints(.init(width: CGFLOAT_MAX, height: CGFLOAT_MAX)).width.rounded(.up), |
| 249 | + height: sizeWithConstraints(.init(width: width, height: CGFLOAT_MAX)).height.rounded(.up) |
| 250 | + ) |
| 251 | + self._intrinsicContentSize = (size: size, boundsWidth: width) |
| 252 | + return size |
| 253 | + } |
| 254 | + |
| 255 | + public override func layoutSubviews() { |
| 256 | + if _intrinsicContentSize?.boundsWidth != bounds.width { |
| 257 | + invalidateIntrinsicContentSize() |
| 258 | + } |
| 259 | + updateTextFrame() |
| 260 | + super.layoutSubviews() |
| 261 | + } |
| 262 | + |
| 263 | + public override func draw(_ rect: CGRect) { |
| 264 | + guard let textFrame else { |
| 265 | + return |
| 266 | + } |
| 267 | + guard let context = UIGraphicsGetCurrentContext() else { |
| 268 | + preconditionFailure() |
| 269 | + } |
| 270 | + context.saveGState() |
| 271 | + context.translateBy(x: 0, y: bounds.maxY) |
| 272 | + context.scaleBy(x: 1, y: -1) |
| 273 | + CTFrameDraw(textFrame, context) |
| 274 | + context.restoreGState() |
| 275 | + } |
| 276 | +} |
0 commit comments