Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.swmansion.enriched.markdown.input

import android.content.Context
import android.graphics.Outline
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import androidx.core.widget.NestedScrollView
import com.facebook.react.bridge.ReadableMap

// Wraps the editor in a scroll container so contentInset behaves like iOS textContainerInset: the
// cushion is part of the scrolled content instead of clipping inside a scrolling EditText.
class EnrichedMarkdownTextInputScrollView(
context: Context,
) : NestedScrollView(context) {
val input = EnrichedMarkdownTextInputView(context)

// scrollEnabled prop, for parity with iOS where false disables scrolling.
var scrollingEnabled = true

// RN swallows the editor's requestLayout and Fabric won't re-lay-out a fixed-height container, so
// re-measure ourselves to update the scroll range when the editor grows.
private val measureAndLayout =
Runnable {
if (width == 0 || height == 0) return@Runnable
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
)
layout(left, top, right, bottom)
}

init {
isFillViewport = true
isVerticalScrollBarEnabled = true
isVerticalFadingEdgeEnabled = false
// RN parents default to overflow:visible and don't clip us, so clip the scrolled editor to the
// viewport ourselves (rounded via the background outline, else a plain rect).
outlineProvider =
object : ViewOutlineProvider() {
override fun getOutline(
view: View,
outline: Outline,
) {
val background = view.background
if (background != null) background.getOutline(outline) else outline.setRect(0, 0, view.width, view.height)
}
}
clipToOutline = true
input.scrollEnabled = false
input.gravity = Gravity.TOP or Gravity.START
addView(
input,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
}

// Mirror the React tag onto the editor so its events and tag-keyed measure resolve to this component.
override fun setId(id: Int) {
super.setId(id)
input.id = id
}

override fun onSizeChanged(
w: Int,
h: Int,
oldw: Int,
oldh: Int,
) {
super.onSizeChanged(w, h, oldw, oldh)
invalidateOutline()
// After autoFocus the keyboard resize finalizes the viewport; re-reveal the caret until the user touches.
if (input.pendingCaretScroll) post { input.scrollContainerToCaret() }
}

override fun requestLayout() {
super.requestLayout()
// Only the scrolling case needs this; auto-grow is re-laid-out by Fabric. Coalesced per frame.
if (!scrollingEnabled) return
removeCallbacks(measureAndLayout)
post(measureAndLayout)
}

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean = scrollingEnabled && super.onInterceptTouchEvent(ev)

override fun onTouchEvent(ev: MotionEvent): Boolean = scrollingEnabled && super.onTouchEvent(ev)

override fun onDetachedFromWindow() {
removeCallbacks(measureAndLayout)
super.onDetachedFromWindow()
}

fun setContentInsetFromProps(value: ReadableMap?) {
input.setContentInsetFromProps(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.graphics.BlendMode
import android.graphics.BlendModeColorFilter
import android.graphics.Color
import android.graphics.Rect
import android.os.Build
import android.text.Editable
import android.text.InputType
Expand All @@ -16,6 +17,7 @@ import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.AppCompatEditText
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.common.ReactConstants
import com.facebook.react.uimanager.BackgroundStyleApplicator
import com.facebook.react.uimanager.PixelUtil
Expand Down Expand Up @@ -67,10 +69,19 @@ class EnrichedMarkdownTextInputView(

var emitMarkdown = false
var autoFocusRequested = false

// Set when autoFocus places the caret at the end; tells the container to scroll the caret into view
// as the viewport settles (content measure, keyboard resize), until the user takes over by touching.
var pendingCaretScroll = false
var stateWrapper: StateWrapper? = null
val layoutManager = InputLayoutManager(this)
private var pendingAutoFocusKeyboard = false

// Internal padding mirroring iOS textContainerInset, in px (left, top, right, bottom). The measure
// adds it back into the auto-grow height so the text keeps the cushion without clipping.
var contentInsetPx = Rect()
private set

private var typefaceDirty = false
private var fontFamilyValue: String? = null
private var fontWeightValue: Int = ReactConstants.UNSET
Expand Down Expand Up @@ -107,7 +118,8 @@ class EnrichedMarkdownTextInputView(
private fun prepareComponent() {
isSingleLine = false
isHorizontalScrollBarEnabled = false
isVerticalScrollBarEnabled = true
// The editor never scrolls itself; the container owns the scrollbar.
isVerticalScrollBarEnabled = false
inputType = InputType.TYPE_CLASS_TEXT or
InputType.TYPE_TEXT_FLAG_MULTI_LINE or
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or
Expand Down Expand Up @@ -136,6 +148,30 @@ class EnrichedMarkdownTextInputView(
}
}

// iOS uses UITextView.textContainerInset; on Android it's editor padding. The editor doesn't scroll
// (the container does), so the cushion is scrolled content and never clips.
fun setContentInsetFromProps(value: ReadableMap?) {
fun px(key: String): Int = if (value?.hasKey(key) == true) PixelUtil.toPixelFromDIP(value.getDouble(key).toFloat()).toInt() else 0
applyEditorInset(Rect(px("left"), px("top"), px("right"), px("bottom")))
}

// React `padding` style, applied like contentInset so the auto-grow measure accounts for it.
// contentInset and padding write the same editor padding, so setting both is last-writer-wins.
fun setReactPadding(
left: Int,
top: Int,
right: Int,
bottom: Int,
) {
applyEditorInset(Rect(left, top, right, bottom))
}

private fun applyEditorInset(inset: Rect) {
contentInsetPx = inset
setPadding(inset.left, inset.top, inset.right, inset.bottom)
layoutManager.invalidateLayout()
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
runAsATransaction { super.setTextIsSelectable(true) }
Expand Down Expand Up @@ -178,6 +214,8 @@ class EnrichedMarkdownTextInputView(
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
detectScrollMovement = true
// The user takes over scrolling/positioning; stop the autoFocus caret-reveal.
pendingCaretScroll = false
parent?.requestDisallowInterceptTouchEvent(true)
}

Expand Down Expand Up @@ -256,7 +294,6 @@ class EnrichedMarkdownTextInputView(
detectorPipeline.processTextChange(editable, currentText, editStart, insertedLength)
}

forceScrollToSelection()
eventEmitter.emitChangeText()
if (emitMarkdown) eventEmitter.emitChangeMarkdown()
updateActiveMention()
Expand Down Expand Up @@ -327,35 +364,20 @@ class EnrichedMarkdownTextInputView(

private fun applyFormattingAndEmit() {
applyFormatting()
forceScrollToSelection()
if (emitMarkdown) eventEmitter.emitChangeMarkdown()
eventEmitter.emitState()
}

private fun forceScrollToSelection() {
// The editor never scrolls itself; ask the scroll container to bring the caret line into view. Used
// after autoFocus places the caret at the end so a long pre-filled value reveals the end, not the top.
fun scrollContainerToCaret() {
val textLayout = layout ?: return
val cursorOffset = selectionStart
if (cursorOffset <= 0) return

val selectedLineIndex = textLayout.getLineForOffset(cursorOffset)
val selectedLineTop = textLayout.getLineTop(selectedLineIndex)
val selectedLineBottom = textLayout.getLineBottom(selectedLineIndex)
val visibleTextHeight = height - paddingTop - paddingBottom
if (visibleTextHeight <= 0) return

val visibleTop = scrollY
val visibleBottom = scrollY + visibleTextHeight
var targetScrollY = scrollY

if (selectedLineTop < visibleTop) {
targetScrollY = selectedLineTop
} else if (selectedLineBottom > visibleBottom) {
targetScrollY = selectedLineBottom - visibleTextHeight
}

val maxScrollY = (textLayout.height - visibleTextHeight).coerceAtLeast(0)
targetScrollY = targetScrollY.coerceIn(0, maxScrollY)
scrollTo(scrollX, targetScrollY)
val line = textLayout.getLineForOffset(selectionStart.coerceAtLeast(0))
val top = paddingTop + textLayout.getLineTop(line)
// Include the bottom inset so revealing the end shows the cushion below the last line (clamped to
// the scroll range, so a caret on a middle line just keeps a little breathing room).
val bottom = paddingTop + textLayout.getLineBottom(line) + paddingBottom
requestRectangleOnScreen(Rect(0, top, width, bottom), true)
}

fun toggleInlineStyle(styleType: StyleType) {
Expand Down Expand Up @@ -572,7 +594,6 @@ class EnrichedMarkdownTextInputView(
setSelection(text?.length ?: 0)
}
applyFormatting()
forceScrollToSelection()
layoutManager.invalidateLayout()
lastProcessedText = text?.toString() ?: ""
} finally {
Expand Down Expand Up @@ -667,6 +688,10 @@ class EnrichedMarkdownTextInputView(
requestFocus()
setSelection(text?.length ?: 0)
showAutoFocusKeyboardIfPending()
// Reveal the caret (now at the end) once the container has measured the content; onSizeChanged
// re-runs it as the keyboard resizes the viewport, until the user touches to take over.
pendingCaretScroll = true
post { scrollContainerToCaret() }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class InputLayoutManager(
val text = view.text
val paint = view.paint

val needUpdate = InputMeasurementStore.store(view.id, text, paint)
val needUpdate = InputMeasurementStore.store(view.id, text, paint, view.contentInsetPx)
if (!needUpdate) return

val state = Arguments.createMap()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.swmansion.enriched.markdown.input.layout

import android.content.Context
import android.graphics.Rect
import android.os.Build
import android.text.StaticLayout
import android.text.TextPaint
Expand All @@ -23,6 +24,7 @@ object InputMeasurementStore {
val cachedSize: Long,
val text: CharSequence?,
val paintParams: PaintParams,
val inset: Rect,
)

private val data = ConcurrentHashMap<Int, MeasurementParams>()
Expand All @@ -31,14 +33,15 @@ object InputMeasurementStore {
id: Int,
text: CharSequence?,
paint: TextPaint,
inset: Rect,
): Boolean {
val cachedWidth = data[id]?.cachedWidth ?: 0f
val cachedSize = data[id]?.cachedSize ?: 0L

val size = measure(cachedWidth, text, paint)
val size = measure(cachedWidth, text, paint, inset)
val paintParams = PaintParams(paint.typeface, paint.textSize)

data[id] = MeasurementParams(cachedWidth, size, text, paintParams)
data[id] = MeasurementParams(cachedWidth, size, text, paintParams, inset)
return size != cachedSize
}

Expand All @@ -55,6 +58,10 @@ object InputMeasurementStore {
props: ReadableMap?,
): Long {
val size = getMeasureByIdInternal(context, id, width, props)
// EXACTLY = fixed frame: fill it and let the container scroll the editor, not collapse to content.
if (heightMode === YogaMeasureMode.EXACTLY) {
return YogaMeasureOutput.make(YogaMeasureOutput.getWidth(size), PixelUtil.toDIPFromPixel(height))
}
if (heightMode !== YogaMeasureMode.AT_MOST) {
return size
}
Expand Down Expand Up @@ -84,8 +91,8 @@ object InputMeasurementStore {
textSize = value.paintParams.fontSize
}

val size = measure(width, value.text, paint)
data[id] = MeasurementParams(width, size, value.text, value.paintParams)
val size = measure(width, value.text, paint, value.inset)
data[id] = MeasurementParams(width, size, value.text, value.paintParams, value.inset)
return size
}

Expand All @@ -110,16 +117,26 @@ object InputMeasurementStore {
isAntiAlias = true
}

return measure(width, text, paint)
return measure(width, text, paint, insetFromProps(props))
}

private fun insetFromProps(props: ReadableMap?): Rect {
val inset = props?.getMap("contentInset") ?: return Rect()

fun px(key: String): Int = if (inset.hasKey(key)) PixelUtil.toPixelFromDIP(inset.getDouble(key).toFloat()).toInt() else 0
return Rect(px("left"), px("top"), px("right"), px("bottom"))
}

private fun measure(
maxWidth: Float,
text: CharSequence?,
paint: TextPaint,
inset: Rect,
): Long {
val content = text ?: ""
val widthPx = maxWidth.toInt().coerceAtLeast(0)
// The text wraps inside the horizontal inset and the vertical inset is added back below, so the
// reported height matches the padded EditText exactly and nothing clips.
val widthPx = (maxWidth.toInt() - inset.left - inset.right).coerceAtLeast(0)

val builder =
StaticLayout.Builder
Expand All @@ -136,7 +153,7 @@ object InputMeasurementStore {
}

val staticLayout = builder.build()
val heightInDip = PixelUtil.toDIPFromPixel(staticLayout.height.toFloat())
val heightInDip = PixelUtil.toDIPFromPixel((staticLayout.height + inset.top + inset.bottom).toFloat())
val widthInDip = PixelUtil.toDIPFromPixel(maxWidth)
return YogaMeasureOutput.make(widthInDip, heightInDip)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class MarkdownTextInputShadowNode final
}

static ShadowNodeTraits BaseTraits() {
// Kept as an auto-grow measuring leaf so consumers without an explicit height still grow to
// content (measureContent measures the child editor via the synced tag); with a definite/EXACTLY
// height the measure fills instead, and the NestedScrollView scrolls the editor inside.
auto traits = ConcreteViewShadowNode::BaseTraits();
traits.set(ShadowNodeTraits::Trait::LeafYogaNode);
traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ inline folly::dynamic toDynamic(const EnrichedMarkdownTextInputProps &props) {
serializedProps["fontWeight"] = props.fontWeight;
serializedProps["fontFamily"] = props.fontFamily;
serializedProps["lineHeight"] = props.lineHeight;
serializedProps["contentInset"] = toDynamic(props.contentInset);

return serializedProps;
}
Expand Down
Loading
Loading