From 2b1f143c29d4a1c5631349d8889822c24fb88861 Mon Sep 17 00:00:00 2001 From: Evgeniy Plokhov Date: Tue, 23 Sep 2025 13:23:32 +0200 Subject: [PATCH 1/2] Extend ComposePlaceholderAdapter to allow effective Composables measuring --- .../placeholders/ComposePlaceholderAdapter.kt | 84 ++++++++++++++++ .../placeholders/ComposePlaceholderManager.kt | 97 ++++++++++++++++--- 2 files changed, 168 insertions(+), 13 deletions(-) diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt index bd5465b6c..a85c12435 100644 --- a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt @@ -4,6 +4,90 @@ import androidx.compose.runtime.Composable import org.wordpress.aztec.AztecAttributes interface ComposePlaceholderAdapter : PlaceholderManager.PlaceholderAdapter { + /** + * Optional sizing hints for the manager. + * + * Usage: + * - Return [SizingPolicy.MatchWidthWrapContentHeight] if your content should + * match the editor width and wrap to its intrinsic height. The manager will + * pre-measure once offscreen to obtain the final height and avoid flicker. + * - Return [SizingPolicy.AspectRatio] for media with known aspect ratio. The + * manager calculates height = width * ratio without composition. + * - Return [SizingPolicy.FixedHeightPx] for fixed-height embeds. + * - Return [SizingPolicy.Unknown] to keep legacy behavior; the manager will + * call your existing [calculateHeight] implementation. + */ + fun sizingPolicy(attrs: AztecAttributes): SizingPolicy = SizingPolicy.Unknown + + /** + * Optional hook to compute a final height before first paint. + * + * When to use: + * - Your content height depends on Compose measurement (e.g., text wrapping) + * and you want a single pass without interim sizes. + * + * How it works: + * - Manager provides a [measurer] that composes your content offscreen at an + * exact width and returns its measured height in pixels. + * - Return that value to have the placeholder sized correctly up-front. + * - Return null to let the manager fall back to [sizingPolicy] or legacy + * [calculateHeight]. + * + * Notes: + * - Runs on the main thread. Do not perform long blocking work here. + * - Keep the content passed to [measurer.measure] minimal (only what affects + * size) to make pre-measure cheap. + */ + suspend fun preComposeMeasureHeight( + attrs: AztecAttributes, + widthPx: Int, + measurer: PlaceholderMeasurer + ): Int? = null + + /** + * Optional spacing added after the placeholder, in pixels. + * + * This increases the reserved text-flow height while keeping the overlay + * view at the content height, producing a visual margin below the embed + * without an extra redraw. + */ + fun bottomSpacingPx(attrs: AztecAttributes): Int = 0 + + /** Abstraction to measure Compose content offscreen at an exact width. */ + interface PlaceholderMeasurer { + suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int + } + + /** Sizing policy hints used by the manager to choose a measurement path. */ + sealed interface SizingPolicy { + object Unknown : SizingPolicy + object MatchWidthWrapContentHeight : SizingPolicy + data class AspectRatio(val ratio: Float) : SizingPolicy + data class FixedHeightPx(val heightPx: Int) : SizingPolicy + } + + /** + * Insets for positioning the overlay view within the reserved text area. + * + * This affects only the overlay position/size, not the reserved text-flow + * height. Use this to keep content away from edges (e.g., rounded corners) + * or to eliminate bottom inset if it causes clipping. + * + * Defaults match legacy behavior (10 px on each side). Return zeros for + * edge-to-edge rendering. + */ + data class OverlayPadding(val left: Int, val top: Int, val right: Int, val bottom: Int) + + fun overlayPaddingPx(attrs: AztecAttributes): OverlayPadding = OverlayPadding(10, 10, 10, 10) + + /** + * Optional tiny positive adjustment added to the overlay height (pixels). + * + * Purpose: guard against 1 px rounding differences between pre-measure and + * runtime composition that could otherwise clip the last row/baseline. + * Leave at 0 unless you observe such edge cases. + */ + fun contentHeightAdjustmentPx(attrs: AztecAttributes): Int = 0 /** * Use this method to draw the placeholder using Jetpack Compose. * @param placeholderUuid the placeholder UUID diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt index dc9e39e7d..6e20b24d9 100644 --- a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt @@ -9,6 +9,10 @@ import android.text.Layout import android.text.Spanned import android.view.View import android.view.ViewTreeObserver +import android.os.Looper +import android.view.ViewGroup +import android.view.View.MeasureSpec +import androidx.compose.ui.platform.ComposeView import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -339,12 +343,11 @@ class ComposePlaceholderManager( val editorWidth = if (aztecText.width > 0) { aztecText.width - aztecText.paddingStart - aztecText.paddingEnd } else aztecText.maxImagesWidth - drawable.setBounds( - 0, - 0, - adapter.calculateWidth(attrs, editorWidth), - adapter.calculateHeight(attrs, editorWidth) - ) + val widthPx = adapter.calculateWidth(attrs, editorWidth) + val heightPx = computeHeightPx(adapter, attrs, editorWidth, widthPx) + // Reserve additional flow space after the placeholder to visually separate following blocks + val flowHeight = heightPx + (adapter.bottomSpacingPx(attrs)) + drawable.setBounds(0, 0, widthPx, flowHeight) return drawable } @@ -409,16 +412,19 @@ class ComposePlaceholderManager( val adapter = adapters[type]!! val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING - val height = adapter.calculateHeight(attrs, windowWidth) + val targetWidth = adapter.calculateWidth(attrs, windowWidth) + val measuredHeight = computeHeightPx(adapter, attrs, windowWidth, targetWidth) + val extraBottom = adapter.bottomSpacingPx(attrs) + val height = measuredHeight + extraBottom parentTextViewRect.top += parentTextViewTopAndBottomOffset parentTextViewRect.bottom = parentTextViewRect.top + height val box = _composeViewState.value[uuid] - val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING - val newHeight = height - EDITOR_INNER_PADDING - val padding = 10 - val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart - val newTopPadding = parentTextViewRect.top + padding + val newWidth = targetWidth + val newHeight = measuredHeight + val overlayPad = adapter.overlayPaddingPx(attrs) + val newLeftPadding = parentTextViewRect.left + overlayPad.left + aztecText.paddingStart + val newTopPadding = parentTextViewRect.top + overlayPad.top box?.let { existingView -> val widthSame = existingView.width == newWidth val heightSame = existingView.height == newHeight @@ -431,10 +437,11 @@ class ComposePlaceholderManager( } _composeViewState.value = _composeViewState.value.let { state -> val mutableState = state.toMutableMap() + val adjustedHeight = newHeight + (adapter.contentHeightAdjustmentPx(attrs)) mutableState[uuid] = ComposeView( uuid = uuid, width = newWidth, - height = newHeight, + height = adjustedHeight, topMargin = newTopPadding, leftMargin = newLeftPadding, visible = true, @@ -445,6 +452,70 @@ class ComposePlaceholderManager( } } + private suspend fun computeHeightPx( + adapter: ComposePlaceholderAdapter, + attrs: AztecAttributes, + windowWidth: Int, + contentWidthPx: Int + ): Int = + when (val policy = adapter.sizingPolicy(attrs)) { + is ComposePlaceholderAdapter.SizingPolicy.FixedHeightPx -> policy.heightPx + + is ComposePlaceholderAdapter.SizingPolicy.AspectRatio -> (policy.ratio * contentWidthPx).toInt() + + ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight -> + preMeasureHeight(adapter, attrs, contentWidthPx) ?: adapter.calculateHeight(attrs, windowWidth) + + ComposePlaceholderAdapter.SizingPolicy.Unknown -> adapter.calculateHeight(attrs, windowWidth) + } + + private suspend fun preMeasureHeight( + adapter: ComposePlaceholderAdapter, + attrs: AztecAttributes, + widthPx: Int + ): Int? { + // Pre-measure only on main thread. If not on main, fall back to legacy path + if (Looper.myLooper() != Looper.getMainLooper()) return null + val measurer = object : ComposePlaceholderAdapter.PlaceholderMeasurer { + override suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int { + if (!aztecText.isAttachedToWindow) return -1 + val parent = aztecText.parent as? ViewGroup ?: return -1 + val composeView = ComposeView(aztecText.context) + composeView.visibility = View.GONE + composeView.layoutParams = ViewGroup.LayoutParams(0, 0) + try { + parent.addView(composeView) + composeView.setContent { + Box( + Modifier + .width(with(LocalDensity.current) { widthPx.toDp() }) + ) { + content() + } + } + val wSpec = MeasureSpec.makeMeasureSpec(widthPx, MeasureSpec.EXACTLY) + val hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) + composeView.measure(wSpec, hSpec) + return composeView.measuredHeight + } catch (_: IllegalStateException) { + return -1 + } finally { + parent.removeView(composeView) + } + } + } + // Let adapter compute/measure if it wants to + val fromAdapter = adapter.preComposeMeasureHeight(attrs, widthPx, measurer) + if (fromAdapter != null && fromAdapter >= 0) return fromAdapter + // If adapter did not implement it but hinted wrap content policy, measure the actual content once + if (adapter.sizingPolicy(attrs) == ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight) { + val uuid = attrs.getValue(UUID_ATTRIBUTE) + val h = measurer.measure(content = { adapter.Placeholder(uuid, attrs) }, widthPx = widthPx) + return if (h >= 0) h else null + } + return null + } + private fun validateAttributes(attributes: AztecAttributes): Boolean { return attributes.hasAttribute(UUID_ATTRIBUTE) && attributes.hasAttribute(TYPE_ATTRIBUTE) && From 7aff4c91d678c804390f96869c80ab7797a1a8b7 Mon Sep 17 00:00:00 2001 From: Evgeniy Plokhov Date: Tue, 23 Sep 2025 21:23:25 +0200 Subject: [PATCH 2/2] Extract the Compose view creation logic into functions --- .../placeholders/ComposePlaceholderManager.kt | 138 +++++++++++++----- 1 file changed, 101 insertions(+), 37 deletions(-) diff --git a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt index 6e20b24d9..85625ea40 100644 --- a/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt +++ b/media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt @@ -4,15 +4,14 @@ package org.wordpress.aztec.placeholders import android.graphics.Rect import android.graphics.drawable.Drawable +import android.os.Looper import android.text.Editable import android.text.Layout import android.text.Spanned import android.view.View -import android.view.ViewTreeObserver -import android.os.Looper -import android.view.ViewGroup import android.view.View.MeasureSpec -import androidx.compose.ui.platform.ComposeView +import android.view.ViewGroup +import android.view.ViewTreeObserver import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -21,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.key import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.zIndex import androidx.core.content.ContextCompat @@ -343,11 +343,23 @@ class ComposePlaceholderManager( val editorWidth = if (aztecText.width > 0) { aztecText.width - aztecText.paddingStart - aztecText.paddingEnd } else aztecText.maxImagesWidth - val widthPx = adapter.calculateWidth(attrs, editorWidth) - val heightPx = computeHeightPx(adapter, attrs, editorWidth, widthPx) - // Reserve additional flow space after the placeholder to visually separate following blocks - val flowHeight = heightPx + (adapter.bottomSpacingPx(attrs)) - drawable.setBounds(0, 0, widthPx, flowHeight) + + if (adapter.sizingPolicy(attrs) != ComposePlaceholderAdapter.SizingPolicy.Unknown) { + // New behavior with enhanced measuring + val widthPx = adapter.calculateWidth(attrs, editorWidth) + val heightPx = computeHeightPx(adapter, attrs, editorWidth, widthPx) + // Reserve additional flow space after the placeholder to visually separate following blocks + val flowHeight = heightPx + (adapter.bottomSpacingPx(attrs)) + drawable.setBounds(0, 0, widthPx, flowHeight) + } else { + // Legacy behavior + drawable.setBounds( + 0, + 0, + adapter.calculateWidth(attrs, editorWidth), + adapter.calculateHeight(attrs, editorWidth) + ) + } return drawable } @@ -412,6 +424,44 @@ class ComposePlaceholderManager( val adapter = adapters[type]!! val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING + + // Check if using new sizing policy or legacy behavior + val newComposeView = if (adapter.sizingPolicy(attrs) != ComposePlaceholderAdapter.SizingPolicy.Unknown) { + createComposeViewWithSizingPolicy( + adapter, attrs, uuid, windowWidth, parentTextViewRect, parentTextViewTopAndBottomOffset + ) + } else { + createComposeViewWithLegacy( + adapter, attrs, uuid, windowWidth, parentTextViewRect, parentTextViewTopAndBottomOffset + ) + } + + // Check if view needs updating + val existingView = _composeViewState.value[uuid] + if (existingView != null && + existingView.width == newComposeView.width && + existingView.height == newComposeView.height && + existingView.topMargin == newComposeView.topMargin && + existingView.leftMargin == newComposeView.leftMargin && + existingView.attrs == attrs + ) { + return + } + + // Update compose view state + _composeViewState.value = _composeViewState.value.toMutableMap().apply { + this[uuid] = newComposeView + } + } + + private suspend fun createComposeViewWithSizingPolicy( + adapter: ComposePlaceholderAdapter, + attrs: AztecAttributes, + uuid: String, + windowWidth: Int, + parentTextViewRect: Rect, + parentTextViewTopAndBottomOffset: Int + ): ComposeView { val targetWidth = adapter.calculateWidth(attrs, windowWidth) val measuredHeight = computeHeightPx(adapter, attrs, windowWidth, targetWidth) val extraBottom = adapter.bottomSpacingPx(attrs) @@ -419,37 +469,51 @@ class ComposePlaceholderManager( parentTextViewRect.top += parentTextViewTopAndBottomOffset parentTextViewRect.bottom = parentTextViewRect.top + height - val box = _composeViewState.value[uuid] - val newWidth = targetWidth - val newHeight = measuredHeight val overlayPad = adapter.overlayPaddingPx(attrs) val newLeftPadding = parentTextViewRect.left + overlayPad.left + aztecText.paddingStart val newTopPadding = parentTextViewRect.top + overlayPad.top - box?.let { existingView -> - val widthSame = existingView.width == newWidth - val heightSame = existingView.height == newHeight - val topMarginSame = existingView.topMargin == newTopPadding - val leftMarginSame = existingView.leftMargin == newLeftPadding - val attrsSame = existingView.attrs == attrs - if (widthSame && heightSame && topMarginSame && leftMarginSame && attrsSame) { - return - } - } - _composeViewState.value = _composeViewState.value.let { state -> - val mutableState = state.toMutableMap() - val adjustedHeight = newHeight + (adapter.contentHeightAdjustmentPx(attrs)) - mutableState[uuid] = ComposeView( - uuid = uuid, - width = newWidth, - height = adjustedHeight, - topMargin = newTopPadding, - leftMargin = newLeftPadding, - visible = true, - adapterKey = adapter.type, - attrs = attrs - ) - mutableState - } + val adjustedHeight = measuredHeight + adapter.contentHeightAdjustmentPx(attrs) + + return ComposeView( + uuid = uuid, + width = targetWidth, + height = adjustedHeight, + topMargin = newTopPadding, + leftMargin = newLeftPadding, + visible = true, + adapterKey = adapter.type, + attrs = attrs + ) + } + + private suspend fun createComposeViewWithLegacy( + adapter: ComposePlaceholderAdapter, + attrs: AztecAttributes, + uuid: String, + windowWidth: Int, + parentTextViewRect: Rect, + parentTextViewTopAndBottomOffset: Int + ): ComposeView { + val height = adapter.calculateHeight(attrs, windowWidth) + parentTextViewRect.top += parentTextViewTopAndBottomOffset + parentTextViewRect.bottom = parentTextViewRect.top + height + + val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING + val newHeight = height - EDITOR_INNER_PADDING + val padding = 10 + val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart + val newTopPadding = parentTextViewRect.top + padding + + return ComposeView( + uuid = uuid, + width = newWidth, + height = newHeight, + topMargin = newTopPadding, + leftMargin = newLeftPadding, + visible = true, + adapterKey = adapter.type, + attrs = attrs + ) } private suspend fun computeHeightPx(