@@ -172,6 +172,8 @@ open class RichTextViewController: UIViewController, NSLayoutManagerDelegate, UI
172172 self . textStorage. beginEditing ( )
173173 self . textStorage. setAttributedString ( output)
174174 self . textStorage. endEditing ( )
175+
176+ layoutElementsOnTextView ( containerSize: textView. bounds. size)
175177
176178 self . calculateAndSetPreferredContentSize ( )
177179 self . applyTextViewStyles ( )
@@ -182,8 +184,35 @@ open class RichTextViewController: UIViewController, NSLayoutManagerDelegate, UI
182184 }
183185
184186 private func calculateAndSetPreferredContentSize( ) {
185- let newContentSize = textView. sizeThatFits ( textView. bounds. size)
186- guard newContentSize != preferredContentSize else {
187+ // Base size from text content
188+ var newContentSize = textView. sizeThatFits ( textView. bounds. size)
189+
190+ // If there are attachment views (like embedded resources) that sit below the text
191+ // they should be accounted for in preferredContentSize. Compute the bottom-most
192+ // point of all attachment subviews we've added and expand the height if needed.
193+ if !attachmentViews. isEmpty {
194+ // Convert attachment subviews' frames to textView coordinate space and find max bottom
195+ let maxAttachmentBottom = attachmentViews. values
196+ . compactMap { $0. superview == textView ? $0. frame. maxY : nil }
197+ . max ( ) ?? 0
198+
199+ // textView content is inset by textView.textContainerInset; preferredContentSize
200+ // should be at least the bottom of the attachments plus bottom inset.
201+ let contentInsetBottom = textView. textContainerInset. bottom
202+ let attachmentsRequiredHeight = maxAttachmentBottom + contentInsetBottom
203+
204+ if attachmentsRequiredHeight > newContentSize. height {
205+ newContentSize. height = attachmentsRequiredHeight
206+ }
207+ }
208+
209+ // Avoid triggering tiny layout changes which could lead to layout loops. Only update
210+ // preferredContentSize if the change is meaningful (epsilon threshold).
211+ let epsilon : CGFloat = 0.5
212+ let heightDelta = abs ( preferredContentSize. height - newContentSize. height)
213+ let widthDelta = abs ( preferredContentSize. width - newContentSize. width)
214+
215+ guard heightDelta > epsilon || widthDelta > epsilon else {
187216 return
188217 }
189218
@@ -213,8 +242,6 @@ open class RichTextViewController: UIViewController, NSLayoutManagerDelegate, UI
213242 return
214243 }
215244
216- layoutElementsOnTextView ( containerSize: textView. bounds. size)
217-
218245 expectedTextViewSizeAfterOrientationChange = nil
219246 }
220247
@@ -274,37 +301,39 @@ open class RichTextViewController: UIViewController, NSLayoutManagerDelegate, UI
274301 - contentInset. left
275302 - contentInset. right
276303
277- let scaleFactor = newWidth > 0 && attrView. frame. width > 0 ? newWidth / attrView. frame. width : max ( attrView. frame. width, newWidth)
278- let newHeight = scaleFactor * attrView. frame. height
304+ // Compute the origin inside the text container using glyph location and line fragment rect
305+ let fragmentOriginX = floor ( lineFragmentRect. minX + glyphLocation. x)
306+ let fragmentOriginY = floor ( lineFragmentRect. minY + glyphLocation. y)
279307
280- // Rect specifying an area where text should not be rendered.
308+ // Rect specifying an area where text should not be rendered (in text container coordinates) .
281309 // The rect is being updated right before the exclusion path is created.
282310 var boundingRect = CGRect (
283- x: lineFragmentRect . minX + glyphLocation . x + contentInset . left - 1 ,
284- y: lineFragmentRect . minY + glyphLocation . y + contentInset. top,
285- width: containerSize . width ,
286- height: newHeight
311+ x: fragmentOriginX ,
312+ y: fragmentOriginY + contentInset. top,
313+ width: newWidth ,
314+ height: 0 // does not matter now as will ask to calculate height below in layout method
287315 )
288316
289- // Rect specifying an area where the attachment is rendered. This can differ from the `boundingRect`.
290- let attachmentRect = CGRect (
291- x: lineFragmentRect. minX + glyphLocation. x + contentInset. left - 1 ,
292- y: lineFragmentRect. minY + glyphLocation. y + contentInset. top,
317+ // Rect specifying an area where the attachment is rendered (in text view coordinates).
318+ // Must add content inset to convert from text container coords to text view coords.
319+ var attachmentRect = CGRect (
320+ x: contentInset. left + fragmentOriginX,
321+ y: contentInset. top + fragmentOriginY,
293322 width: newWidth,
294- height: newHeight
323+ height: 0 // does not matter now as will ask to calculate height below in layout method
295324 )
296-
325+
297326 if attrView. superview == nil {
298- attrView. frame = attachmentRect
299-
300327 attachmentCastedView. layout ( with: attachmentRect. width)
301328
302329 let exclusionKey = String ( range. hashValue) + Constant. embedSuffix
303330
304331 // Update bounding rect after laying out the view.
305332 let updatedRect = attachmentCastedView. frame
306- boundingRect. size. height = updatedRect. height
307-
333+ boundingRect. size. height = floor ( updatedRect. height)
334+ attachmentRect. size. height = floor ( updatedRect. height)
335+ attrView. frame = attachmentRect
336+
308337 addExclusionPath ( for: boundingRect, key: exclusionKey)
309338
310339 textView. addSubview ( attrView)
0 commit comments