diff --git a/.maestro/enrichedInput/flows/image_width_clamping.yaml b/.maestro/enrichedInput/flows/image_width_clamping.yaml new file mode 100644 index 000000000..68a43bf23 --- /dev/null +++ b/.maestro/enrichedInput/flows/image_width_clamping.yaml @@ -0,0 +1,21 @@ +appId: swmansion.enriched.example +--- +# verifies the image width is clamped down to the total available space, and wraps properly +- launchApp + +- tapOn: + id: "toggle-screen-button" + +- runFlow: + file: "../subflows/set_editor_value.yaml" + env: + VALUE: > +

+ +- tapOn: + id: "size-max-button" + +- runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "image_width_clamping" diff --git a/.maestro/enrichedInput/screenshots/android/image_width_clamping.png b/.maestro/enrichedInput/screenshots/android/image_width_clamping.png new file mode 100644 index 000000000..58bbf14d4 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/image_width_clamping.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/image_width_clamping.png b/.maestro/enrichedInput/screenshots/ios/image_width_clamping.png new file mode 100644 index 000000000..f22bc0e90 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/image_width_clamping.png differ diff --git a/.maestro/enrichedText/flows/image_wrap.yaml b/.maestro/enrichedText/flows/image_wrap.yaml new file mode 100644 index 000000000..01bccee7a --- /dev/null +++ b/.maestro/enrichedText/flows/image_wrap.yaml @@ -0,0 +1,26 @@ +appId: swmansion.enriched.example +--- +# Validates that images wrap correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > +

+ +- swipe: + start: "5%, 35%" + end: "5%, 35%" + duration: 3000 + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'image_wrap' diff --git a/.maestro/enrichedText/screenshots/android/image_wrap.png b/.maestro/enrichedText/screenshots/android/image_wrap.png new file mode 100644 index 000000000..205bb10f7 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/image_wrap.png differ diff --git a/.maestro/enrichedText/screenshots/ios/image_wrap.png b/.maestro/enrichedText/screenshots/ios/image_wrap.png new file mode 100644 index 000000000..fbba58411 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/image_wrap.png differ diff --git a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt index ba09436ce..ecc7877bc 100644 --- a/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/common/spans/EnrichedImageSpan.kt @@ -23,6 +23,7 @@ import java.io.File open class EnrichedImageSpan : ImageSpan, EnrichedInlineSpan { + var containerWidth: Int? = null private var width: Int = 0 private var height: Int = 0 @@ -53,8 +54,19 @@ open class EnrichedImageSpan : override fun getDrawable(): Drawable { val drawable = super.getDrawable() val scale = Resources.getSystem().displayMetrics.density + val maxWidth = this.containerWidth - drawable.setBounds(0, 0, (width * scale).toInt(), (height * scale).toInt()) + var targetWidth = (width * scale).toInt() + var targetHeight = (height * scale).toInt() + + // clamp down the width to the maximum available container space + if (maxWidth != null && targetWidth > maxWidth && maxWidth > 0) { + val aspectRatio = targetHeight.toFloat() / targetWidth.toFloat() + targetWidth = maxWidth + targetHeight = (targetWidth * aspectRatio).toInt() + } + + drawable.setBounds(0, 0, targetWidth, targetHeight) return drawable } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt index 357016da3..1456010ee 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -24,6 +24,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedSpanFlags +import com.swmansion.enriched.common.ForceRedrawSpan import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp @@ -87,6 +88,31 @@ class EnrichedTextView : AppCompatTextView { setFontSize(EnrichedConstants.TEXT_DEFAULT_FONT_SIZE) } + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + + if (widthMode != MeasureSpec.UNSPECIFIED) { + val availableWidth = widthSize - totalPaddingLeft - totalPaddingRight + + if (availableWidth > 0) { + val spanned = text as? Spanned + val spans = spanned?.getSpans(0, spanned.length, EnrichedTextImageSpan::class.java) + + // we need to update the image bounds, potentially clamping + // the width to the available one + spans?.forEach { + it.containerWidth = availableWidth + } + } + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + override fun onTouchEvent(event: MotionEvent): Boolean { val spanned = text as? Spanned val action = event.action diff --git a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt index f2805659c..da5ad64de 100644 --- a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -23,6 +23,7 @@ import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.allowFontScalingFromProps import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp +import com.swmansion.enriched.text.spans.EnrichedTextImageSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import kotlin.math.ceil @@ -165,6 +166,13 @@ object MeasurementStore { val lineHeight = lineHeightFromProps(props) val allowFontScaling = allowFontScalingFromProps(props) + // we need to update the image bounds, potentially clamping + // the width to the available one + (rawText as? Spannable)?.let { spannable -> + val imageSpans = spannable.getSpans(0, spannable.length, EnrichedTextImageSpan::class.java) + imageSpans.forEach { it.containerWidth = width.toInt() } + } + val measuredText: CharSequence = if (lineHeight > 0f) { val spannable = SpannableString(rawText) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt index e61435f35..38b477e75 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -175,6 +175,31 @@ class EnrichedTextInputView : super.scrollTo(0, y) } + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + + if (widthMode != MeasureSpec.UNSPECIFIED) { + val availableWidth = widthSize - totalPaddingLeft - totalPaddingRight + + if (availableWidth > 0) { + val spannable = text as? Spannable + val spans = spannable?.getSpans(0, spannable.length, EnrichedInputImageSpan::class.java) + + // we need to update the image bounds, potentially clamping + // the width to the available one + spans?.forEach { + it.containerWidth = availableWidth + } + } + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { var inputConnection = super.onCreateInputConnection(outAttrs) diff --git a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt index ea309ef8a..893388738 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt @@ -19,6 +19,7 @@ import com.facebook.yoga.YogaMeasureOutput import com.swmansion.enriched.common.allowFontScalingFromProps import com.swmansion.enriched.common.parser.EnrichedParser import com.swmansion.enriched.common.pixelFromSpOrDp +import com.swmansion.enriched.textinput.spans.EnrichedInputImageSpan import com.swmansion.enriched.textinput.spans.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.styles.HtmlStyle import java.util.concurrent.ConcurrentHashMap @@ -170,6 +171,13 @@ object MeasurementStore { rawText } + // we need to update the image bounds, potentially clamping + // the width to the available one + (text as? Spannable)?.let { spannable -> + val imageSpans = spannable.getSpans(0, spannable.length, EnrichedInputImageSpan::class.java) + imageSpans.forEach { it.containerWidth = width.toInt() } + } + val typeface = applyStyles(defaultView.typeface, fontStyle, fontWeight, fontFamily, context.assets) val paintParams = PaintParams(typeface, fontSize) val size = measure(width, text, paintParams) diff --git a/ios/interfaces/ImageAttachment.mm b/ios/interfaces/ImageAttachment.mm index 491a20777..6c5893640 100644 --- a/ios/interfaces/ImageAttachment.mm +++ b/ios/interfaces/ImageAttachment.mm @@ -1,5 +1,6 @@ #import "ImageAttachment.h" #import "extensions/ImageExtension.h" +#import "utils/AttachmentLayoutUtils.h" // NSTextStorage frequently recreates NSTextAttachment objects during attribute // invalidation (e.g. on every keystroke). Without this cache each recreation @@ -49,6 +50,10 @@ - (CGRect)attachmentBoundsForTextContainer:(NSTextContainer *)textContainer characterIndex:(NSUInteger)charIndex { CGRect baseBounds = self.bounds; + baseBounds.size = + [AttachmentLayoutUtils clampedSize:baseBounds.size + toMaxWidth:textContainer.size.width]; + if (!textContainer.layoutManager.textStorage || charIndex >= textContainer.layoutManager.textStorage.length) { return baseBounds; diff --git a/ios/utils/AttachmentLayoutUtils.h b/ios/utils/AttachmentLayoutUtils.h index 6245302c2..84faf3286 100644 --- a/ios/utils/AttachmentLayoutUtils.h +++ b/ios/utils/AttachmentLayoutUtils.h @@ -21,4 +21,6 @@ textView:(UITextView *)textView config:(EnrichedConfig *)config; ++ (CGSize)clampedSize:(CGSize)size toMaxWidth:(CGFloat)maxWidth; + @end diff --git a/ios/utils/AttachmentLayoutUtils.mm b/ios/utils/AttachmentLayoutUtils.mm index ace6ee346..90bc44cd3 100644 --- a/ios/utils/AttachmentLayoutUtils.mm +++ b/ios/utils/AttachmentLayoutUtils.mm @@ -121,7 +121,8 @@ + (CGRect)frameForAttachment:(ImageAttachment *)attachment CGRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphRange.location effectiveRange:NULL]; - CGSize attachmentSize = attachment.bounds.size; + CGSize attachmentSize = [self clampedSize:attachment.bounds.size + toMaxWidth:textContainer.size.width]; UIFont *font = [storage attribute:NSFontAttributeName atIndex:range.location @@ -141,4 +142,12 @@ + (CGRect)frameForAttachment:(ImageAttachment *)attachment return CGRectIntegral(rect); } ++ (CGSize)clampedSize:(CGSize)size toMaxWidth:(CGFloat)maxWidth { + if (size.width > maxWidth && maxWidth > 0) { + CGFloat scale = maxWidth / size.width; + return CGSizeMake(maxWidth, size.height * scale); + } + return size; +} + @end