new implementation based on the usage of NSTextAttachment to allocate space for the timestamp

This commit is contained in:
Mauro Romito
2026-05-11 16:59:40 +02:00
committed by Mauro
parent deac31e48d
commit 686753721e
3 changed files with 115 additions and 26 deletions
@@ -101,6 +101,25 @@ extension String {
} }
} }
extension String {
/// Whether the first character with a strong BiDi direction is right-to-left.
/// Mirrors the Unicode BiDi "first strong" rule used by TextKit to resolve
/// paragraph direction when `baseWritingDirection` is `.natural`.
var firstStrongCharacterIsRTL: Bool {
for scalar in unicodeScalars {
let value = scalar.value
// Strong RTL: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic,
// Arabic Extended, and their presentation forms.
let isStrongRTL = (0x0590...0x08FF).contains(value) ||
(0xFB1D...0xFDFF).contains(value) ||
(0xFE70...0xFEFF).contains(value)
if isStrongRTL { return true }
if scalar.properties.isAlphabetic { return false }
}
return false
}
}
extension String { extension String {
/// To be used if the string is actually a URL /// To be used if the string is actually a URL
var asSanitizedLink: String { var asSanitizedLink: String {
+63 -2
View File
@@ -55,9 +55,25 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG
} }
} }
/// An `NSTextAttachment` that takes up its declared `bounds` but draws nothing — used
/// as an invisible spacer so a message bubble's text reserves room for the overlaid
/// timestamp. Without this subclass, an attachment with no image would render TextKit's
/// default "missing image" glyph.
private final class TransparentTextAttachment: NSTextAttachment {
override func image(forBounds imageBounds: CGRect,
textContainer: NSTextContainer?,
characterIndex charIndex: Int) -> UIImage? {
let format = UIGraphicsImageRendererFormat()
format.opaque = false
let size = CGSize(width: max(imageBounds.width, 1), height: max(imageBounds.height, 1))
return UIGraphicsImageRenderer(size: size, format: format).image { _ in }
}
}
struct MessageText: UIViewRepresentable { struct MessageText: UIViewRepresentable {
@Environment(\.openURL) private var openURLAction @Environment(\.openURL) private var openURLAction
@Environment(\.timelineContext) private var viewModel @Environment(\.timelineContext) private var viewModel
@Environment(\.layoutDirection) private var layoutDirection
@State private var computedSizes = [Double: CGSize]() @State private var computedSizes = [Double: CGSize]()
@State var attributedString: AttributedString { @State var attributedString: AttributedString {
@@ -66,6 +82,50 @@ struct MessageText: UIViewRepresentable {
} }
} }
/// Reserves an invisible inline area of this size at the very end of the rendered
/// text. Used so that the message bubble's natural size accommodates the timestamp
/// overlaid on top of the bubble's bottom-trailing corner. TextKit decides whether
/// the reserved region fits on the last line (timestamp tucks) or wraps to a new
/// line (timestamp drops below the text).
var trailingReservedSize: CGSize = .zero
private func makeAttributedText() -> NSAttributedString? {
guard let baseText = try? NSAttributedString(attributedString, including: \.elementX) else {
return nil
}
let combined = NSMutableAttributedString(attributedString: baseText)
if trailingReservedSize.width > 0 {
let attachment = TransparentTextAttachment()
attachment.isAccessibilityElement = false
attachment.bounds = CGRect(origin: .zero,
size: CGSize(width: trailingReservedSize.width,
height: max(trailingReservedSize.height, 1)))
// When the last paragraph's natural text direction doesn't match the layout
// direction, the inline attachment would land on the wrong side and overlap the
// overlaid timestamp. Force it onto its own line so the bubble just grows taller.
if !lastParagraphDirectionMatchesLayout(in: combined) {
combined.append(NSAttributedString(string: "\n"))
}
combined.append(NSAttributedString(attachment: attachment))
}
return combined
}
private func lastParagraphDirectionMatchesLayout(in attributedText: NSAttributedString) -> Bool {
let string = attributedText.string as NSString
guard string.length > 0 else { return true }
let lastParagraphRange = string.paragraphRange(for: NSRange(location: string.length - 1, length: 0))
let lastParagraph = string.substring(with: lastParagraphRange)
let textIsRTL = lastParagraph.firstStrongCharacterIsRTL
return textIsRTL == (layoutDirection == .rightToLeft)
}
func makeUIView(context: Context) -> MessageTextView { func makeUIView(context: Context) -> MessageTextView {
// Need to use TextKit 1 for mentions // Need to use TextKit 1 for mentions
let textView = MessageTextView(usingTextLayoutManager: false) let textView = MessageTextView(usingTextLayoutManager: false)
@@ -97,7 +157,7 @@ struct MessageText: UIViewRepresentable {
textView.textContainer.lineFragmentPadding = 0 textView.textContainer.lineFragmentPadding = 0
textView.layoutManager.usesFontLeading = false textView.layoutManager.usesFontLeading = false
textView.backgroundColor = .clear textView.backgroundColor = .clear
if let attributedText = try? NSAttributedString(attributedString, including: \.elementX) { if let attributedText = makeAttributedText() {
textView.attributedText = attributedText textView.attributedText = attributedText
} }
textView.delegate = context.coordinator textView.delegate = context.coordinator
@@ -105,10 +165,11 @@ struct MessageText: UIViewRepresentable {
} }
func updateUIView(_ uiView: MessageTextView, context: Context) { func updateUIView(_ uiView: MessageTextView, context: Context) {
if let newAttributedText = try? NSAttributedString(attributedString, including: \.elementX), if let newAttributedText = makeAttributedText(),
uiView.attributedText != newAttributedText { uiView.attributedText != newAttributedText {
uiView.flushPills() uiView.flushPills()
uiView.attributedText = newAttributedText uiView.attributedText = newAttributedText
computedSizes.removeAll()
} }
context.coordinator.openURLAction = openURLAction context.coordinator.openURLAction = openURLAction
} }
@@ -64,18 +64,27 @@ extension EventBasedTimelineItemProtocol {
} }
} }
func additionalWhitespaces() -> Int { /// The inline area to reserve at the trailing edge of the message text so the
var whiteSpaces = 1 /// bubble's natural size accommodates the timestamp (which is overlaid on the
localizedSendInfo.forEach { _ in /// bubble's bottom-trailing corner). Text wraps around this area: if it fits on
whiteSpaces += 1 /// the last line, the timestamp tucks inline; otherwise it sits on a new line.
} /// Sized using the same dynamic-type-aware font as the rendered timestamp.
var trailingReservedSize: CGSize {
// To account for the extra spacing created by the status icon let textStyle: UIFont.TextStyle = .caption1 // matches compound.bodyXS
let font = UIFont.preferredFont(forTextStyle: textStyle)
let textSize = (localizedSendInfo as NSString).size(withAttributes: [.font: font])
var width = textSize.width
var height = textSize.height
if hasStatusIcon { if hasStatusIcon {
whiteSpaces += 3 // CompoundIcon at .xSmall is 16pt at the base size; scale with the same UIFontMetrics
// as the icon's `relativeTo: .compound.bodyXS` modifier, plus the 4pt HStack spacing.
let iconSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: 16)
width += iconSize + 4
height = max(height, iconSize)
} }
// Small visual gap between the text content and the overlaid timestamp.
return whiteSpaces width += 4
return CGSize(width: ceil(width), height: ceil(height))
} }
/// contains the timestamp and an optional edited localised prefix /// contains the timestamp and an optional edited localised prefix