Skip to content

Commit 2b1f143

Browse files
committed
Extend ComposePlaceholderAdapter to allow effective Composables measuring
1 parent 5219fa9 commit 2b1f143

File tree

2 files changed

+168
-13
lines changed

2 files changed

+168
-13
lines changed

media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderAdapter.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,90 @@ import androidx.compose.runtime.Composable
44
import org.wordpress.aztec.AztecAttributes
55

66
interface ComposePlaceholderAdapter : PlaceholderManager.PlaceholderAdapter {
7+
/**
8+
* Optional sizing hints for the manager.
9+
*
10+
* Usage:
11+
* - Return [SizingPolicy.MatchWidthWrapContentHeight] if your content should
12+
* match the editor width and wrap to its intrinsic height. The manager will
13+
* pre-measure once offscreen to obtain the final height and avoid flicker.
14+
* - Return [SizingPolicy.AspectRatio] for media with known aspect ratio. The
15+
* manager calculates height = width * ratio without composition.
16+
* - Return [SizingPolicy.FixedHeightPx] for fixed-height embeds.
17+
* - Return [SizingPolicy.Unknown] to keep legacy behavior; the manager will
18+
* call your existing [calculateHeight] implementation.
19+
*/
20+
fun sizingPolicy(attrs: AztecAttributes): SizingPolicy = SizingPolicy.Unknown
21+
22+
/**
23+
* Optional hook to compute a final height before first paint.
24+
*
25+
* When to use:
26+
* - Your content height depends on Compose measurement (e.g., text wrapping)
27+
* and you want a single pass without interim sizes.
28+
*
29+
* How it works:
30+
* - Manager provides a [measurer] that composes your content offscreen at an
31+
* exact width and returns its measured height in pixels.
32+
* - Return that value to have the placeholder sized correctly up-front.
33+
* - Return null to let the manager fall back to [sizingPolicy] or legacy
34+
* [calculateHeight].
35+
*
36+
* Notes:
37+
* - Runs on the main thread. Do not perform long blocking work here.
38+
* - Keep the content passed to [measurer.measure] minimal (only what affects
39+
* size) to make pre-measure cheap.
40+
*/
41+
suspend fun preComposeMeasureHeight(
42+
attrs: AztecAttributes,
43+
widthPx: Int,
44+
measurer: PlaceholderMeasurer
45+
): Int? = null
46+
47+
/**
48+
* Optional spacing added after the placeholder, in pixels.
49+
*
50+
* This increases the reserved text-flow height while keeping the overlay
51+
* view at the content height, producing a visual margin below the embed
52+
* without an extra redraw.
53+
*/
54+
fun bottomSpacingPx(attrs: AztecAttributes): Int = 0
55+
56+
/** Abstraction to measure Compose content offscreen at an exact width. */
57+
interface PlaceholderMeasurer {
58+
suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int
59+
}
60+
61+
/** Sizing policy hints used by the manager to choose a measurement path. */
62+
sealed interface SizingPolicy {
63+
object Unknown : SizingPolicy
64+
object MatchWidthWrapContentHeight : SizingPolicy
65+
data class AspectRatio(val ratio: Float) : SizingPolicy
66+
data class FixedHeightPx(val heightPx: Int) : SizingPolicy
67+
}
68+
69+
/**
70+
* Insets for positioning the overlay view within the reserved text area.
71+
*
72+
* This affects only the overlay position/size, not the reserved text-flow
73+
* height. Use this to keep content away from edges (e.g., rounded corners)
74+
* or to eliminate bottom inset if it causes clipping.
75+
*
76+
* Defaults match legacy behavior (10 px on each side). Return zeros for
77+
* edge-to-edge rendering.
78+
*/
79+
data class OverlayPadding(val left: Int, val top: Int, val right: Int, val bottom: Int)
80+
81+
fun overlayPaddingPx(attrs: AztecAttributes): OverlayPadding = OverlayPadding(10, 10, 10, 10)
82+
83+
/**
84+
* Optional tiny positive adjustment added to the overlay height (pixels).
85+
*
86+
* Purpose: guard against 1 px rounding differences between pre-measure and
87+
* runtime composition that could otherwise clip the last row/baseline.
88+
* Leave at 0 unless you observe such edge cases.
89+
*/
90+
fun contentHeightAdjustmentPx(attrs: AztecAttributes): Int = 0
791
/**
892
* Use this method to draw the placeholder using Jetpack Compose.
993
* @param placeholderUuid the placeholder UUID

media-placeholders/src/main/java/org/wordpress/aztec/placeholders/ComposePlaceholderManager.kt

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import android.text.Layout
99
import android.text.Spanned
1010
import android.view.View
1111
import android.view.ViewTreeObserver
12+
import android.os.Looper
13+
import android.view.ViewGroup
14+
import android.view.View.MeasureSpec
15+
import androidx.compose.ui.platform.ComposeView
1216
import androidx.compose.foundation.layout.Box
1317
import androidx.compose.foundation.layout.height
1418
import androidx.compose.foundation.layout.padding
@@ -339,12 +343,11 @@ class ComposePlaceholderManager(
339343
val editorWidth = if (aztecText.width > 0) {
340344
aztecText.width - aztecText.paddingStart - aztecText.paddingEnd
341345
} else aztecText.maxImagesWidth
342-
drawable.setBounds(
343-
0,
344-
0,
345-
adapter.calculateWidth(attrs, editorWidth),
346-
adapter.calculateHeight(attrs, editorWidth)
347-
)
346+
val widthPx = adapter.calculateWidth(attrs, editorWidth)
347+
val heightPx = computeHeightPx(adapter, attrs, editorWidth, widthPx)
348+
// Reserve additional flow space after the placeholder to visually separate following blocks
349+
val flowHeight = heightPx + (adapter.bottomSpacingPx(attrs))
350+
drawable.setBounds(0, 0, widthPx, flowHeight)
348351
return drawable
349352
}
350353

@@ -409,16 +412,19 @@ class ComposePlaceholderManager(
409412

410413
val adapter = adapters[type]!!
411414
val windowWidth = parentTextViewRect.right - parentTextViewRect.left - EDITOR_INNER_PADDING
412-
val height = adapter.calculateHeight(attrs, windowWidth)
415+
val targetWidth = adapter.calculateWidth(attrs, windowWidth)
416+
val measuredHeight = computeHeightPx(adapter, attrs, windowWidth, targetWidth)
417+
val extraBottom = adapter.bottomSpacingPx(attrs)
418+
val height = measuredHeight + extraBottom
413419
parentTextViewRect.top += parentTextViewTopAndBottomOffset
414420
parentTextViewRect.bottom = parentTextViewRect.top + height
415421

416422
val box = _composeViewState.value[uuid]
417-
val newWidth = adapter.calculateWidth(attrs, windowWidth) - EDITOR_INNER_PADDING
418-
val newHeight = height - EDITOR_INNER_PADDING
419-
val padding = 10
420-
val newLeftPadding = parentTextViewRect.left + padding + aztecText.paddingStart
421-
val newTopPadding = parentTextViewRect.top + padding
423+
val newWidth = targetWidth
424+
val newHeight = measuredHeight
425+
val overlayPad = adapter.overlayPaddingPx(attrs)
426+
val newLeftPadding = parentTextViewRect.left + overlayPad.left + aztecText.paddingStart
427+
val newTopPadding = parentTextViewRect.top + overlayPad.top
422428
box?.let { existingView ->
423429
val widthSame = existingView.width == newWidth
424430
val heightSame = existingView.height == newHeight
@@ -431,10 +437,11 @@ class ComposePlaceholderManager(
431437
}
432438
_composeViewState.value = _composeViewState.value.let { state ->
433439
val mutableState = state.toMutableMap()
440+
val adjustedHeight = newHeight + (adapter.contentHeightAdjustmentPx(attrs))
434441
mutableState[uuid] = ComposeView(
435442
uuid = uuid,
436443
width = newWidth,
437-
height = newHeight,
444+
height = adjustedHeight,
438445
topMargin = newTopPadding,
439446
leftMargin = newLeftPadding,
440447
visible = true,
@@ -445,6 +452,70 @@ class ComposePlaceholderManager(
445452
}
446453
}
447454

455+
private suspend fun computeHeightPx(
456+
adapter: ComposePlaceholderAdapter,
457+
attrs: AztecAttributes,
458+
windowWidth: Int,
459+
contentWidthPx: Int
460+
): Int =
461+
when (val policy = adapter.sizingPolicy(attrs)) {
462+
is ComposePlaceholderAdapter.SizingPolicy.FixedHeightPx -> policy.heightPx
463+
464+
is ComposePlaceholderAdapter.SizingPolicy.AspectRatio -> (policy.ratio * contentWidthPx).toInt()
465+
466+
ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight ->
467+
preMeasureHeight(adapter, attrs, contentWidthPx) ?: adapter.calculateHeight(attrs, windowWidth)
468+
469+
ComposePlaceholderAdapter.SizingPolicy.Unknown -> adapter.calculateHeight(attrs, windowWidth)
470+
}
471+
472+
private suspend fun preMeasureHeight(
473+
adapter: ComposePlaceholderAdapter,
474+
attrs: AztecAttributes,
475+
widthPx: Int
476+
): Int? {
477+
// Pre-measure only on main thread. If not on main, fall back to legacy path
478+
if (Looper.myLooper() != Looper.getMainLooper()) return null
479+
val measurer = object : ComposePlaceholderAdapter.PlaceholderMeasurer {
480+
override suspend fun measure(content: @Composable () -> Unit, widthPx: Int): Int {
481+
if (!aztecText.isAttachedToWindow) return -1
482+
val parent = aztecText.parent as? ViewGroup ?: return -1
483+
val composeView = ComposeView(aztecText.context)
484+
composeView.visibility = View.GONE
485+
composeView.layoutParams = ViewGroup.LayoutParams(0, 0)
486+
try {
487+
parent.addView(composeView)
488+
composeView.setContent {
489+
Box(
490+
Modifier
491+
.width(with(LocalDensity.current) { widthPx.toDp() })
492+
) {
493+
content()
494+
}
495+
}
496+
val wSpec = MeasureSpec.makeMeasureSpec(widthPx, MeasureSpec.EXACTLY)
497+
val hSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
498+
composeView.measure(wSpec, hSpec)
499+
return composeView.measuredHeight
500+
} catch (_: IllegalStateException) {
501+
return -1
502+
} finally {
503+
parent.removeView(composeView)
504+
}
505+
}
506+
}
507+
// Let adapter compute/measure if it wants to
508+
val fromAdapter = adapter.preComposeMeasureHeight(attrs, widthPx, measurer)
509+
if (fromAdapter != null && fromAdapter >= 0) return fromAdapter
510+
// If adapter did not implement it but hinted wrap content policy, measure the actual content once
511+
if (adapter.sizingPolicy(attrs) == ComposePlaceholderAdapter.SizingPolicy.MatchWidthWrapContentHeight) {
512+
val uuid = attrs.getValue(UUID_ATTRIBUTE)
513+
val h = measurer.measure(content = { adapter.Placeholder(uuid, attrs) }, widthPx = widthPx)
514+
return if (h >= 0) h else null
515+
}
516+
return null
517+
}
518+
448519
private fun validateAttributes(attributes: AztecAttributes): Boolean {
449520
return attributes.hasAttribute(UUID_ATTRIBUTE) &&
450521
attributes.hasAttribute(TYPE_ATTRIBUTE) &&

0 commit comments

Comments
 (0)