Skip to content

Commit 516e6f0

Browse files
committed
Add MMMTextLayout to mix attributed text with views
1 parent 7d8e10d commit 516e6f0

36 files changed

+518
-4
lines changed

MMMCommonUI.podspec

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Pod::Spec.new do |s|
1919

2020
s.subspec 'ObjC' do |ss|
2121
ss.source_files = [ "Sources/#{s.name}ObjC/*.{h,m}" ]
22-
ss.dependency 'MMMCommonCore', '~> 1.15'
22+
ss.dependency 'MMMCommonCore'
2323
ss.dependency 'MMMLoadable/ObjC'
2424
ss.dependency 'MMMLog/ObjC'
2525
ss.dependency 'MMMObservables/ObjC'
@@ -33,14 +33,16 @@ Pod::Spec.new do |s|
3333
s.subspec 'Swift' do |ss|
3434
ss.source_files = [ "Sources/#{s.name}/*.swift" ]
3535
ss.dependency "#{s.name}/ObjC"
36-
ss.dependency 'MMMCommonCore', '~> 1.15'
36+
ss.dependency 'MMMCommonCore'
3737
ss.dependency 'MMMLoadable'
3838
ss.dependency 'MMMLog'
3939
ss.dependency 'MMMObservables'
4040
end
4141

4242
s.test_spec 'Tests' do |ss|
4343
ss.source_files = "Tests/*.{m,swift}"
44+
ss.scheme = { :environment_variables => {'FB_REFERENCE_IMAGE_DIR' => "${PODS_TARGET_SRCROOT}/Tests/Snapshots" } }
45+
ss.dependency 'MMMTestCase'
4446
end
4547

4648
s.default_subspec = 'ObjC', 'Swift'
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//
2+
// MMMCommonUI. Part of MMMTemple.
3+
// Copyright (C) 2016-2025 Monks. All rights reserved.
4+
//
5+
6+
import Foundation
7+
8+
extension NSAttributedString {
9+
10+
/// Builds an attributed string out of a plain template string and attributes, replacing placeholders like
11+
/// `%@` or `%2$@` in it with attributed strings from `args`.
12+
///
13+
/// - Parameters:
14+
///
15+
/// - format: The usual formatting template string with regular (`%@`) or positional string placeholders (`%1$@`).
16+
///
17+
/// Note that non-string placeholders like `%d` are not supported!
18+
///
19+
/// - attributes: The attributes that all parts of the `format` string except placeholders are going to have.
20+
///
21+
/// - args: The attributed strings that should be used instead of placeholders.
22+
///
23+
/// Note that the attributes of these strings completely replace those in `attributes`.
24+
///
25+
/// - invalidPlaceholder: The closure that is called for placeholders having no matching strings in `args`.
26+
/// The zero-based index of the placeholder is passed and the returned value is used for this invalid placeholder.
27+
///
28+
/// If this is not provided, then invalid placeholders are deleted.
29+
///
30+
/// This allows to make a wrapper that stops in Debug releases to notice the issue earlier; it also allows to return something
31+
/// different from an empty string for for testing purposes.
32+
public convenience init(
33+
format: String,
34+
attributes: [NSAttributedString.Key: Any],
35+
args: [NSAttributedString],
36+
invalidPlaceholder: ((_ index: Int) -> NSAttributedString)? = nil
37+
) {
38+
// NSString is easier and safer when regular expressions and ranges are involved.
39+
let _format = format as NSString
40+
41+
let output = NSMutableAttributedString()
42+
43+
var nextArgIndex = 0
44+
var prefixStart = 0
45+
46+
Self.placeholderRegex.enumerateMatches(
47+
in: _format as String,
48+
options: [],
49+
range: NSRange(location: 0, length: _format.length)
50+
) { (result, flags, stop) in
51+
52+
guard let r = result?.range else {
53+
// When can it be nil exactly?
54+
assertionFailure()
55+
return
56+
}
57+
58+
let argumentIndex: Int = {
59+
// Is this a placeholder specifying a 1-based argument index, like i in `%i$@`?
60+
guard let positionRange = result?.range(withName: "position"), positionRange.location != NSNotFound else {
61+
// Well, must be a regular `%@` placeholder, so just taking the next unused argument.
62+
let position = nextArgIndex
63+
nextArgIndex += 1
64+
return position
65+
}
66+
guard let position = Int(_format.substring(with: positionRange)) else {
67+
// We capture only digits there, so should not fail.
68+
preconditionFailure()
69+
}
70+
return position - 1
71+
}()
72+
73+
let argument: NSAttributedString
74+
if args.indices.contains(argumentIndex) {
75+
argument = args[argumentIndex]
76+
} else {
77+
argument = invalidPlaceholder?(argumentIndex) ?? NSAttributedString()
78+
}
79+
80+
let beforePlaceholder = _format.substring(with: .init(location: prefixStart, length: r.location - prefixStart))
81+
output.append(.init(string: beforePlaceholder, attributes: attributes))
82+
prefixStart = r.location + r.length
83+
output.append(argument)
84+
}
85+
86+
// The remaining part of the formatting string after the last placeholder, if any.
87+
let suffix = _format.substring(with: .init(location: prefixStart, length: _format.length - prefixStart))
88+
output.append(.init(string: suffix, attributes: attributes))
89+
90+
self.init(attributedString: output)
91+
}
92+
93+
private static var placeholderRegex = try! NSRegularExpression(pattern: "%((?<position>\\d+)\\$)?@", options: [])
94+
}

0 commit comments

Comments
 (0)