mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
new implementation based on the usage of NSTextAttachment to allocate space for the timestamp
This commit is contained in:
@@ -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 {
|
||||
/// To be used if the string is actually a URL
|
||||
var asSanitizedLink: String {
|
||||
|
||||
@@ -13,7 +13,7 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG
|
||||
var timelineContext: TimelineViewModel.Context?
|
||||
var updateClosure: (() -> Void)?
|
||||
private var pillViews = NSHashTable<UIView>.weakObjects()
|
||||
|
||||
|
||||
override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
// We don't need to change the behaviour on MacOS
|
||||
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
@@ -41,11 +41,11 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate, UIG
|
||||
updateClosure?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func registerPillView(_ pillView: UIView) {
|
||||
pillViews.add(pillView)
|
||||
}
|
||||
|
||||
|
||||
func flushPills() {
|
||||
for view in pillViews.allObjects {
|
||||
view.alpha = 0.0
|
||||
@@ -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 {
|
||||
@Environment(\.openURL) private var openURLAction
|
||||
@Environment(\.timelineContext) private var viewModel
|
||||
@Environment(\.layoutDirection) private var layoutDirection
|
||||
@State private var computedSizes = [Double: CGSize]()
|
||||
|
||||
@State var attributedString: AttributedString {
|
||||
@@ -65,7 +81,51 @@ struct MessageText: UIViewRepresentable {
|
||||
computedSizes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
// Need to use TextKit 1 for mentions
|
||||
let textView = MessageTextView(usingTextLayoutManager: false)
|
||||
@@ -82,7 +142,7 @@ struct MessageText: UIViewRepresentable {
|
||||
textView.isEditable = false
|
||||
textView.isScrollEnabled = false
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
|
||||
|
||||
// Required to allow tapping links
|
||||
// We disable selection at delegate level
|
||||
textView.isSelectable = true
|
||||
@@ -90,29 +150,30 @@ struct MessageText: UIViewRepresentable {
|
||||
|
||||
// Otherwise links can be dragged and dropped when long pressed
|
||||
textView.textDragInteraction?.isEnabled = false
|
||||
|
||||
|
||||
textView.contentInset = .zero
|
||||
textView.contentInsetAdjustmentBehavior = .never
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.layoutManager.usesFontLeading = false
|
||||
textView.backgroundColor = .clear
|
||||
if let attributedText = try? NSAttributedString(attributedString, including: \.elementX) {
|
||||
if let attributedText = makeAttributedText() {
|
||||
textView.attributedText = attributedText
|
||||
}
|
||||
textView.delegate = context.coordinator
|
||||
return textView
|
||||
}
|
||||
|
||||
|
||||
func updateUIView(_ uiView: MessageTextView, context: Context) {
|
||||
if let newAttributedText = try? NSAttributedString(attributedString, including: \.elementX),
|
||||
if let newAttributedText = makeAttributedText(),
|
||||
uiView.attributedText != newAttributedText {
|
||||
uiView.flushPills()
|
||||
uiView.attributedText = newAttributedText
|
||||
computedSizes.removeAll()
|
||||
}
|
||||
context.coordinator.openURLAction = openURLAction
|
||||
}
|
||||
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: MessageTextView, context: Context) -> CGSize? {
|
||||
let proposalWidth = proposal.width ?? UIView.layoutFittingExpandedSize.width
|
||||
|
||||
@@ -126,14 +187,14 @@ struct MessageText: UIViewRepresentable {
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(openURLAction: openURLAction)
|
||||
}
|
||||
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate {
|
||||
var openURLAction: OpenURLAction
|
||||
|
||||
|
||||
init(openURLAction: OpenURLAction) {
|
||||
self.openURLAction = openURLAction
|
||||
}
|
||||
@@ -157,7 +218,7 @@ struct MessageText: UIViewRepresentable {
|
||||
}
|
||||
return defaultAction
|
||||
}
|
||||
|
||||
|
||||
func textView(_ textView: UITextView, menuConfigurationFor textItem: UITextItem, defaultMenu: UIMenu) -> UITextItem.MenuConfiguration? {
|
||||
switch textItem.content {
|
||||
case let .link(url):
|
||||
@@ -196,14 +257,14 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
|
||||
.mergeAttributes(defaultFontContainer)
|
||||
return attributedString
|
||||
}()
|
||||
|
||||
|
||||
private static let htmlStringWithQuote =
|
||||
"""
|
||||
<blockquote>A blockquote that is long and goes onto multiple lines as the first item in the message</blockquote><p>Then another line of text here to reply to the blockquote, which is also a multiline component.</p>
|
||||
"""
|
||||
|
||||
private static let htmlStringWithList = "<p>This is a list</p>\n<ul><li>One</li>\n<li>Two</li>\n<li>And number 3</li>\n</ul>\n"
|
||||
|
||||
|
||||
private static let attributedStringBuilder = AttributedStringBuilder(mentionBuilder: MentionBuilder())
|
||||
|
||||
static var attachmentPreview: some View {
|
||||
@@ -211,7 +272,7 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
|
||||
.border(Color.purple)
|
||||
.environmentObject(TimelineViewModel.mock.context)
|
||||
}
|
||||
|
||||
|
||||
static var previews: some View {
|
||||
MessageText(attributedString: attributedString)
|
||||
.border(Color.purple)
|
||||
|
||||
+19
-10
@@ -64,18 +64,27 @@ extension EventBasedTimelineItemProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func additionalWhitespaces() -> Int {
|
||||
var whiteSpaces = 1
|
||||
localizedSendInfo.forEach { _ in
|
||||
whiteSpaces += 1
|
||||
}
|
||||
|
||||
// To account for the extra spacing created by the status icon
|
||||
/// The inline area to reserve at the trailing edge of the message text so the
|
||||
/// bubble's natural size accommodates the timestamp (which is overlaid on the
|
||||
/// bubble's bottom-trailing corner). Text wraps around this area: if it fits on
|
||||
/// 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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
return whiteSpaces
|
||||
// Small visual gap between the text content and the overlaid timestamp.
|
||||
width += 4
|
||||
return CGSize(width: ceil(width), height: ceil(height))
|
||||
}
|
||||
|
||||
/// contains the timestamp and an optional edited localised prefix
|
||||
|
||||
Reference in New Issue
Block a user