diff --git a/.maestro/enrichedInput/flows/font_scaling.yaml b/.maestro/enrichedInput/flows/font_scaling.yaml new file mode 100644 index 000000000..38ce16624 --- /dev/null +++ b/.maestro/enrichedInput/flows/font_scaling.yaml @@ -0,0 +1,57 @@ +appId: swmansion.enriched.example +tags: + - accessibility +--- +- launchApp + +- tapOn: + id: "toggle-screen-button" + +- runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "font_scaling__placeholder" + +- tapOn: + id: "editor-input" + +- inputText: "Plain " + +- tapOn: + id: "toolbar-bold" +- inputText: "bold" +- tapOn: + id: "toolbar-bold" +- inputText: " " + +- tapOn: + id: "toolbar-italic" +- inputText: "italic" +- tapOn: + id: "toolbar-italic" +- inputText: " " + +- tapOn: + id: "toolbar-underline" +- inputText: "underline" +- tapOn: + id: "toolbar-underline" +- inputText: " " + +- tapOn: + id: "toolbar-strikethrough" +- inputText: "strike-through" +- tapOn: + id: "toolbar-strikethrough" +- inputText: " " + +- tapOn: + id: "toolbar-inline-code" +- inputText: "code" +- tapOn: + id: "toolbar-inline-code" + +- runFlow: + file: "../subflows/capture_or_assert_screenshot.yaml" + env: + SCREENSHOT_NAME: "font_scaling" diff --git a/.maestro/enrichedInput/screenshots/android/font_scaling.png b/.maestro/enrichedInput/screenshots/android/font_scaling.png new file mode 100644 index 000000000..6b955aa4e Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/font_scaling.png differ diff --git a/.maestro/enrichedInput/screenshots/android/font_scaling__placeholder.png b/.maestro/enrichedInput/screenshots/android/font_scaling__placeholder.png new file mode 100644 index 000000000..1288cc4b3 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/android/font_scaling__placeholder.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/font_scaling.png b/.maestro/enrichedInput/screenshots/ios/font_scaling.png new file mode 100644 index 000000000..ccc86279c Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/font_scaling.png differ diff --git a/.maestro/enrichedInput/screenshots/ios/font_scaling__placeholder.png b/.maestro/enrichedInput/screenshots/ios/font_scaling__placeholder.png new file mode 100644 index 000000000..99c329c32 Binary files /dev/null and b/.maestro/enrichedInput/screenshots/ios/font_scaling__placeholder.png differ diff --git a/.maestro/enrichedText/flows/font_scaling.yaml b/.maestro/enrichedText/flows/font_scaling.yaml new file mode 100644 index 000000000..faa23a0a7 --- /dev/null +++ b/.maestro/enrichedText/flows/font_scaling.yaml @@ -0,0 +1,34 @@ +appId: swmansion.enriched.example +tags: + - accessibility +--- +# Validates that text alignment is displayed correctly +- launchApp + +- tapOn: + id: 'toggle-screen-button' + +- tapOn: + id: 'toggle-enriched-text-screen-button' + +- runFlow: + file: '../subflows/set_enriched_text_value.yaml' + env: + VALUE: > + +

Left aligned

+

Centre aligned

+
Heading 6
+

Right aligned

+
    +
  1. Element 1
  2. +
  3. Element 2
  4. +
+ + +- scroll + +- runFlow: + file: '../subflows/capture_or_assert_screenshot.yaml' + env: + SCREENSHOT_NAME: 'font_scaling' diff --git a/.maestro/enrichedText/screenshots/android/font_scaling.png b/.maestro/enrichedText/screenshots/android/font_scaling.png new file mode 100644 index 000000000..1547d1e91 Binary files /dev/null and b/.maestro/enrichedText/screenshots/android/font_scaling.png differ diff --git a/.maestro/enrichedText/screenshots/ios/font_scaling.png b/.maestro/enrichedText/screenshots/ios/font_scaling.png new file mode 100644 index 000000000..eb1481f54 Binary files /dev/null and b/.maestro/enrichedText/screenshots/ios/font_scaling.png differ diff --git a/.maestro/scripts/run-tests.sh b/.maestro/scripts/run-tests.sh index 4e9230d64..55fcaef94 100755 --- a/.maestro/scripts/run-tests.sh +++ b/.maestro/scripts/run-tests.sh @@ -71,6 +71,25 @@ app_installed() { fi } +# Adjusts the system's text-size setting. +set_font_scale() { + case "$1" in + default) ios_size="large"; android_scale="1.0" ;; + large) ios_size="accessibility-large"; android_scale="1.5" ;; + *) echo "set_font_scale: unknown size '$1'" >&2; return 1 ;; + esac + if [ "$PLATFORM" = ios ]; then + xcrun simctl ui "$DEVICE_ID" content_size "$ios_size" + else + adb -s "$DEVICE_ID" shell settings put system font_scale "$android_scale" + fi +} + +# Guarantees the font scale is restored on any exit. +# Without this, the accessibility tests below would leave the device +# in a scaled-up state. +trap 'set_font_scale default' EXIT + if [ -n "$REBUILD" ] || ! app_installed; then [ -n "$REBUILD" ] && echo "=== rebuild requested, building and installing ===" [ -z "$REBUILD" ] && echo "=== App ($BUNDLE_ID) not found, building and installing ===" @@ -97,6 +116,18 @@ esac ASSETS_DIR="$MAESTRO_ROOT/assets" [ -d "$ASSETS_DIR" ] && FLOWS="$ASSETS_DIR $FLOWS" +# A previous run could have died before its EXIT trap fired (e.g. SIGKILL), +# leaving the device scaled. Force a known state before the normal tests. +set_font_scale default + echo "=== Running maestro tests ===" # shellcheck disable=SC2086 -maestro test --device "$DEVICE_ID" $EXTRA $FLOWS +maestro test --device "$DEVICE_ID" --exclude-tags accessibility $EXTRA $FLOWS + +# These are the tests that require changing the system's settings +# - something maestro cannot run internally +echo "=== Running maestro accessibility tests ===" +set_font_scale large + +# shellcheck disable=SC2086 +maestro test --device "$DEVICE_ID" --include-tags accessibility $EXTRA $FLOWS diff --git a/android/src/main/java/com/swmansion/enriched/common/AllowFontScaling.kt b/android/src/main/java/com/swmansion/enriched/common/AllowFontScaling.kt new file mode 100644 index 000000000..05f5075d1 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/common/AllowFontScaling.kt @@ -0,0 +1,36 @@ +package com.swmansion.enriched.common + +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.uimanager.PixelUtil + +internal const val ALLOW_FONT_SCALING_PROP = "allowFontScaling" + +// Converts a logical font-unit value to pixels. +// Respects the system font scaling, depending on the `allowFontScaling` value +internal fun pixelFromSpOrDp( + value: Float, + allowFontScaling: Boolean, +): Float = + if (allowFontScaling) { + PixelUtil.toPixelFromSP(value) + } else { + PixelUtil.toPixelFromDIP(value) + } + +internal fun pixelFromSpOrDp( + value: Double, + allowFontScaling: Boolean, +): Float = + if (allowFontScaling) { + PixelUtil.toPixelFromSP(value) + } else { + PixelUtil.toPixelFromDIP(value) + } + +// Reads allowFontScaling from a serialized prop map (used in MeasurementStore +// where no view instance is available yet). +internal fun allowFontScalingFromProps(props: ReadableMap?): Boolean { + if (props == null) return EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT + if (!props.hasKey(ALLOW_FONT_SCALING_PROP) || props.isNull(ALLOW_FONT_SCALING_PROP)) return EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT + return props.getBoolean(ALLOW_FONT_SCALING_PROP) +} diff --git a/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt b/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt index fb68f2049..5a79cc57e 100644 --- a/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt +++ b/android/src/main/java/com/swmansion/enriched/common/EnrichedConstants.kt @@ -11,4 +11,6 @@ object EnrichedConstants { const val TEXT_DEFAULT_FONT_SIZE = 16f const val CLIPBOARD_TAG = "react-native-enriched-clipboard" + + const val ALLOW_FONT_SCALING_DEFAULT = true } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt index e6d6c7461..d263b5d61 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextStyle.kt @@ -4,10 +4,10 @@ import android.graphics.Color import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.uimanager.PixelUtil import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedStyle import com.swmansion.enriched.common.MentionStyle +import com.swmansion.enriched.common.pixelFromSpOrDp import kotlin.math.ceil data class EnrichedTextStyle( @@ -63,6 +63,7 @@ data class EnrichedTextStyle( context: ReactContext, fontSize: Int, map: ReadableMap, + allowFontScaling: Boolean, ): EnrichedTextStyle { val h1 = map.getMap("h1") val h2 = map.getMap("h2") @@ -80,40 +81,40 @@ data class EnrichedTextStyle( val mentions = map.getMap("mention") return EnrichedTextStyle( - h1FontSize = parseFloat(h1, "fontSize").toInt(), + h1FontSize = parseFloat(h1, "fontSize", allowFontScaling).toInt(), h1Bold = h1?.getBoolean("bold") ?: false, - h2FontSize = parseFloat(h2, "fontSize").toInt(), + h2FontSize = parseFloat(h2, "fontSize", allowFontScaling).toInt(), h2Bold = h2?.getBoolean("bold") ?: false, - h3FontSize = parseFloat(h3, "fontSize").toInt(), + h3FontSize = parseFloat(h3, "fontSize", allowFontScaling).toInt(), h3Bold = h3?.getBoolean("bold") ?: false, - h4FontSize = parseFloat(h4, "fontSize").toInt(), + h4FontSize = parseFloat(h4, "fontSize", allowFontScaling).toInt(), h4Bold = h4?.getBoolean("bold") ?: false, - h5FontSize = parseFloat(h5, "fontSize").toInt(), + h5FontSize = parseFloat(h5, "fontSize", allowFontScaling).toInt(), h5Bold = h5?.getBoolean("bold") ?: false, - h6FontSize = parseFloat(h6, "fontSize").toInt(), + h6FontSize = parseFloat(h6, "fontSize", allowFontScaling).toInt(), h6Bold = h6?.getBoolean("bold") ?: false, blockquoteColor = parseOptionalColor(context, blockquote, "color"), blockquoteBorderColor = parseColor(context, blockquote, "borderColor"), - blockquoteStripeWidth = parseFloat(blockquote, "borderWidth").toInt(), - blockquoteGapWidth = parseFloat(blockquote, "gapWidth").toInt(), - olGapWidth = parseFloat(orderedList, "gapWidth").toInt(), - olMarginLeft = calculateOlMarginLeft(fontSize, parseFloat(orderedList, "marginLeft").toInt()), + blockquoteStripeWidth = parseFloat(blockquote, "borderWidth", allowFontScaling).toInt(), + blockquoteGapWidth = parseFloat(blockquote, "gapWidth", allowFontScaling).toInt(), + olGapWidth = parseFloat(orderedList, "gapWidth", allowFontScaling).toInt(), + olMarginLeft = calculateOlMarginLeft(fontSize, parseFloat(orderedList, "marginLeft", allowFontScaling).toInt()), olMarkerFontWeight = parseOptionalFontWeight(orderedList, "markerFontWeight"), olMarkerColor = parseOptionalColor(context, orderedList, "markerColor"), - ulGapWidth = parseFloat(unorderedList, "gapWidth").toInt(), - ulMarginLeft = parseFloat(unorderedList, "marginLeft").toInt(), - ulBulletSize = parseFloat(unorderedList, "bulletSize").toInt(), + ulGapWidth = parseFloat(unorderedList, "gapWidth", allowFontScaling).toInt(), + ulMarginLeft = parseFloat(unorderedList, "marginLeft", allowFontScaling).toInt(), + ulBulletSize = parseFloat(unorderedList, "bulletSize", allowFontScaling).toInt(), ulBulletColor = parseColor(context, unorderedList, "bulletColor"), ulCheckboxBoxColor = parseColor(context, checkboxList, "boxColor"), - ulCheckboxBoxSize = parseFloat(checkboxList, "boxSize").toInt(), - ulCheckboxGapWidth = parseFloat(checkboxList, "gapWidth").toInt(), - ulCheckboxMarginLeft = parseFloat(checkboxList, "marginLeft").toInt(), + ulCheckboxBoxSize = parseFloat(checkboxList, "boxSize", allowFontScaling).toInt(), + ulCheckboxGapWidth = parseFloat(checkboxList, "gapWidth", allowFontScaling).toInt(), + ulCheckboxMarginLeft = parseFloat(checkboxList, "marginLeft", allowFontScaling).toInt(), aColor = parseColor(context, link, "color"), aUnderline = parseIsUnderline(link), aPressColor = parseColor(context, link, "pressColor"), codeBlockColor = parseColor(context, codeblock, "color"), codeBlockBackgroundColor = parseColorWithOpacity(context, codeblock, "backgroundColor", 80), - codeBlockRadius = parseFloat(codeblock, "borderRadius"), + codeBlockRadius = parseFloat(codeblock, "borderRadius", allowFontScaling), inlineCodeColor = parseColor(context, inlineCode, "color"), inlineCodeBackgroundColor = parseColorWithOpacity(context, inlineCode, "backgroundColor", 80), mentionsStyle = parseMentionsStyle(context, mentions), @@ -123,9 +124,10 @@ data class EnrichedTextStyle( private fun parseFloat( map: ReadableMap?, key: String, + allowFontScaling: Boolean, ): Float { if (map == null || !map.hasKey(key) || map.isNull(key)) return 0f - return ceil(PixelUtil.toPixelFromSP(map.getDouble(key))) + return ceil(pixelFromSpOrDp(map.getDouble(key), allowFontScaling)) } private fun parseColor( 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 7ff8ccd5e..d3133ee8c 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextView.kt @@ -18,7 +18,6 @@ import androidx.appcompat.widget.AppCompatTextView import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants -import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ViewDefaults import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle @@ -26,6 +25,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.text.spans.EnrichedTextImageSpan import com.swmansion.enriched.text.spans.interfaces.EnrichedTextClickableSpan import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan @@ -39,6 +39,15 @@ class EnrichedTextView : AppCompatTextView { private var fontStyle: Int = ReactConstants.UNSET private var fontWeight: Int = ReactConstants.UNSET private var fontSize: Float = EnrichedConstants.TEXT_DEFAULT_FONT_SIZE + private var fontSizeRaw: Float? = null + private var htmlStyleMap: ReadableMap? = null + var allowFontScaling: Boolean = EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT + set(value) { + if (field == value) return + field = value + fontSizeRaw?.let { setFontSize(it) } + htmlStyleMap?.let { setHtmlStyle(it) } + } private var enrichedStyle: EnrichedTextStyle? = null private val spannableFactory = EnrichedTextSpanFactory() @@ -247,7 +256,9 @@ class EnrichedTextView : AppCompatTextView { fun setHtmlStyle(style: ReadableMap?) { if (style == null) return - val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize.toInt(), style) + htmlStyleMap = style + val enrichedStyle = + EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize.toInt(), style, allowFontScaling) this.enrichedStyle = enrichedStyle val currentText = text ?: return @@ -287,7 +298,8 @@ class EnrichedTextView : AppCompatTextView { fun setFontSize(size: Float) { if (size == 0f) return - val sizeInt = ceil(PixelUtil.toPixelFromSP(size)) + fontSizeRaw = size + val sizeInt = ceil(pixelFromSpOrDp(size, allowFontScaling)) fontSize = sizeInt setTextSize(TypedValue.COMPLEX_UNIT_PX, sizeInt) } diff --git a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt index e3fb3b083..62f91256c 100644 --- a/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/text/EnrichedTextViewManager.kt @@ -121,6 +121,13 @@ class EnrichedTextViewManager : view?.setHtmlStyle(value) } + override fun setAllowFontScaling( + view: EnrichedTextView?, + value: Boolean, + ) { + view?.allowFontScaling = value + } + override fun setUseHtmlNormalizer( view: EnrichedTextView?, value: Boolean, 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 e07e8080a..690f2a831 100644 --- a/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/text/MeasurementStore.kt @@ -17,7 +17,9 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.yoga.YogaMeasureMode import com.facebook.yoga.YogaMeasureOutput import com.swmansion.enriched.common.EnrichedConstants +import com.swmansion.enriched.common.allowFontScalingFromProps import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.common.pixelFromSpOrDp import kotlin.math.ceil object MeasurementStore { @@ -108,7 +110,9 @@ object MeasurementStore { try { val style = props?.getMap("htmlStyle") ?: return text - val enrichedStyle = EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style) + val allowFontScaling = allowFontScalingFromProps(props) + val enrichedStyle = + EnrichedTextStyle.fromReadableMap(context as ReactContext, fontSize, style, allowFontScaling) val factory = EnrichedTextSpanFactory() val parsed = EnrichedParser.fromHtml(text, enrichedStyle, factory) return parsed.trimEnd('\n') @@ -126,7 +130,7 @@ object MeasurementStore { else -> EnrichedConstants.TEXT_DEFAULT_FONT_SIZE } - return ceil(PixelUtil.toPixelFromSP(fontSize)) + return ceil(pixelFromSpOrDp(fontSize, allowFontScalingFromProps(props))) } private fun getMeasureById( 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 885a12ea4..87518d515 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -32,7 +32,6 @@ import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.common.ReactConstants -import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.StateWrapper import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.text.ReactTypefaceUtils.applyStyles @@ -41,6 +40,7 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.GumboNormalizer import com.swmansion.enriched.common.parser.EnrichedParser +import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.textinput.events.MentionHandler import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent import com.swmansion.enriched.textinput.events.OnInputBlurEvent @@ -88,6 +88,20 @@ class EnrichedTextInputView : var isDuringTransaction: Boolean = false var isRemovingMany: Boolean = false var scrollEnabled: Boolean = true + var allowFontScaling: Boolean = EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT + set(value) { + if (field != value) { + field = value + val raw = fontSizeRaw + if (raw != null) { + setFontSize(raw) // re-invokes invalidateStyles internally + } else { + htmlStyle.invalidateStyles() + } + applyLineSpacing() + reApplyHtmlStyleForSpans(htmlStyle, htmlStyle) // force re-apply + } + } val mentionHandler: MentionHandler? = MentionHandler(this) var htmlStyle: HtmlStyle = HtmlStyle(this, null) @@ -108,6 +122,7 @@ class EnrichedTextInputView : var experimentalSynchronousEvents: Boolean = false var useHtmlNormalizer: Boolean = false + private var fontSizeRaw: Float? = null var fontSize: Float? = null private var lineHeight: Float? = null var submitBehavior: String? = null @@ -520,8 +535,9 @@ class EnrichedTextInputView : fun setFontSize(size: Float) { if (size == 0f) return + fontSizeRaw = size - val sizeInt = ceil(PixelUtil.toPixelFromSP(size)) + val sizeInt = ceil(pixelFromSpOrDp(size, allowFontScaling)) fontSize = sizeInt setTextSize(TypedValue.COMPLEX_UNIT_PX, sizeInt) @@ -546,7 +562,7 @@ class EnrichedTextInputView : val lh = lineHeight ?: return spannable.setSpan( - EnrichedLineHeightSpan(lh), + EnrichedLineHeightSpan(lh, allowFontScaling), 0, spannable.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt index 7b5313462..eb16451ca 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -241,6 +241,14 @@ class EnrichedTextInputViewManager : view.scrollEnabled = scrollEnabled } + @ReactProp(name = "allowFontScaling") + override fun setAllowFontScaling( + view: EnrichedTextInputView, + allowFontScaling: Boolean, + ) { + view.allowFontScaling = allowFontScaling + } + override fun onAfterUpdateTransaction(view: EnrichedTextInputView) { super.onAfterUpdateTransaction(view) view.afterUpdateTransaction() 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 59e83383c..09363e08a 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/MeasurementStore.kt @@ -16,7 +16,9 @@ import com.facebook.react.views.text.ReactTypefaceUtils.parseFontStyle import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight import com.facebook.yoga.YogaMeasureMode 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.EnrichedLineHeightSpan import com.swmansion.enriched.textinput.styles.HtmlStyle import java.util.concurrent.ConcurrentHashMap @@ -130,7 +132,7 @@ object MeasurementStore { val propsFontSize = props?.getDouble("fontSize")?.toFloat() if (propsFontSize == null) return defaultView.textSize - return ceil(PixelUtil.toPixelFromSP(propsFontSize)) + return ceil(pixelFromSpOrDp(propsFontSize, allowFontScalingFromProps(props))) } // Called when view measurements are not available in the store @@ -142,6 +144,9 @@ object MeasurementStore { props: ReadableMap?, ): Long { val defaultView = EnrichedTextInputView(context) + val allowFontScaling = allowFontScalingFromProps(props) + // mirrors the real view's state + defaultView.allowFontScaling = allowFontScaling val rawText = getInitialText(defaultView, props) val fontSize = getInitialFontSize(defaultView, props) @@ -155,7 +160,7 @@ object MeasurementStore { if (lineHeight > 0f) { val spannable = SpannableString(rawText) spannable.setSpan( - EnrichedLineHeightSpan(lineHeight), + EnrichedLineHeightSpan(lineHeight, allowFontScaling), 0, spannable.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedLineHeightSpan.kt b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedLineHeightSpan.kt index 83495bd79..ddaeb7a5d 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedLineHeightSpan.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/spans/EnrichedLineHeightSpan.kt @@ -5,11 +5,12 @@ import android.text.Spannable import android.text.TextPaint import android.text.style.LineHeightSpan import android.text.style.MetricAffectingSpan -import com.facebook.react.uimanager.PixelUtil +import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.common.spans.interfaces.EnrichedHeadingSpan class EnrichedLineHeightSpan( val lineHeight: Float, + val allowFontScaling: Boolean, ) : MetricAffectingSpan(), LineHeightSpan { override fun updateDrawState(p0: TextPaint?) { @@ -33,7 +34,7 @@ class EnrichedLineHeightSpan( // In the future we may consider adding custom lineHeight support for each paragraph style if (spannable.getSpans(start, end, EnrichedHeadingSpan::class.java).isNotEmpty()) return - val lineHeightPx = PixelUtil.toPixelFromSP(lineHeight) + val lineHeightPx = pixelFromSpOrDp(lineHeight, allowFontScaling) val currentHeight = (fm.descent - fm.ascent).toFloat() if (lineHeightPx <= currentHeight) return diff --git a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt index d72db9eb0..3918e2a22 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/styles/HtmlStyle.kt @@ -4,10 +4,11 @@ import android.graphics.Color import com.facebook.react.bridge.ColorPropConverter import com.facebook.react.bridge.ReactContext import com.facebook.react.bridge.ReadableMap -import com.facebook.react.uimanager.PixelUtil import com.facebook.react.views.text.ReactTypefaceUtils.parseFontWeight +import com.swmansion.enriched.common.EnrichedConstants import com.swmansion.enriched.common.EnrichedStyle import com.swmansion.enriched.common.MentionStyle +import com.swmansion.enriched.common.pixelFromSpOrDp import com.swmansion.enriched.textinput.EnrichedTextInputView import kotlin.Float import kotlin.Int @@ -153,9 +154,8 @@ class HtmlStyle : EnrichedStyle { key: String, ): Float { val safeMap = ensureValueIsSet(map, key) - val value = safeMap.getDouble(key) - return ceil(PixelUtil.toPixelFromSP(value)) + return ceil(pixelFromSpOrDp(value, view?.allowFontScaling ?: EnrichedConstants.ALLOW_FONT_SCALING_DEFAULT)) } private fun parseColorWithOpacity( diff --git a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h index f77dd91ec..7c4dcb629 100644 --- a/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h +++ b/android/src/main/new_arch/react/renderer/components/ReactNativeEnrichedSpec/conversions.h @@ -18,6 +18,7 @@ inline folly::dynamic toDynamic(const EnrichedTextInputViewProps &props) { serializedProps["fontStyle"] = props.fontStyle; serializedProps["fontFamily"] = props.fontFamily; serializedProps["lineHeight"] = props.lineHeight; + serializedProps["allowFontScaling"] = props.allowFontScaling; serializedProps["htmlStyle"] = toDynamic(props.htmlStyle); return serializedProps; @@ -34,6 +35,7 @@ inline folly::dynamic toDynamic(const EnrichedTextViewProps &props) { serializedProps["fontFamily"] = props.fontFamily; serializedProps["numberOfLines"] = props.numberOfLines; serializedProps["ellipsizeMode"] = props.ellipsizeMode; + serializedProps["allowFontScaling"] = props.allowFontScaling; serializedProps["htmlStyle"] = toDynamic(props.htmlStyle); return serializedProps; diff --git a/apps/example/src/screens/DevScreen.tsx b/apps/example/src/screens/DevScreen.tsx index 04c8d03ed..c2cb45c40 100644 --- a/apps/example/src/screens/DevScreen.tsx +++ b/apps/example/src/screens/DevScreen.tsx @@ -75,6 +75,7 @@ export function DevScreen({ onSwitch }: DevScreenProps) { onPasteImages={(e) => editor.handlePasteImagesEvent(e.nativeEvent)} useHtmlNormalizer testID="editor-input" + allowFontScaling /> (_props); + if (!viewProps.allowFontScaling) { + return; + } + if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { [config invalidateFonts]; diff --git a/ios/EnrichedTextView.mm b/ios/EnrichedTextView.mm index 5f6384b6a..da00a7f1a 100644 --- a/ios/EnrichedTextView.mm +++ b/ios/EnrichedTextView.mm @@ -582,6 +582,14 @@ - (void)updateProps:(Props::Shared const &)props useHtmlNormalizer = newViewProps.useHtmlNormalizer; } + // allowFontScaling + if (newViewProps.allowFontScaling != oldViewProps.allowFontScaling || + isFirstMount) { + textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + [newConfig setAllowFontScaling:newViewProps.allowFontScaling]; + stylePropChanged = YES; + } + if (stylePropChanged) { config = newConfig; } @@ -698,6 +706,39 @@ - (void)tryUpdatingHeight { _state->updateState(EnrichedTextViewState(selfRef)); } +/** + * Handles iOS Dynamic Type changes (user changing font size in System + * Settings). + * + * Unlike Android, iOS Views do not automatically rescale existing + * NSAttributedStrings when the system font size changes. The text attributes + * are static once drawn, so we re-parse the HTML to rebuild every run with + * fonts at the new content size category. + */ +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { + [super traitCollectionDidChange:previousTraitCollection]; + + if (_props == nullptr) { + return; + } + + const auto &viewProps = + *std::static_pointer_cast(_props); + if (!viewProps.allowFontScaling) { + return; + } + + if (previousTraitCollection.preferredContentSizeCategory == + self.traitCollection.preferredContentSizeCategory) { + return; + } + + [config invalidateFonts]; + [self syncDefaultTypingAttributesFromConfig]; + [self renderText:[NSString fromCppString:viewProps.text]]; + [self tryUpdatingHeight]; +} + - (std::shared_ptr)getEventEmitter { if (_eventEmitter != nullptr) { auto emitter = diff --git a/ios/config/EnrichedConfig.h b/ios/config/EnrichedConfig.h index a49fb501f..d39248baf 100644 --- a/ios/config/EnrichedConfig.h +++ b/ios/config/EnrichedConfig.h @@ -90,6 +90,8 @@ - (void)setCodeBlockBorderRadius:(CGFloat)newValue; - (void)invalidateFonts; - (NSNumber *)scaledPrimaryFontSize; +- (BOOL)allowFontScaling; +- (void)setAllowFontScaling:(BOOL)newValue; - (CGFloat)checkboxListBoxSize; - (void)setCheckboxListBoxSize:(CGFloat)newValue; - (CGFloat)checkboxListGapWidth; diff --git a/ios/config/EnrichedConfig.mm b/ios/config/EnrichedConfig.mm index 0322b88bc..f7a492d28 100644 --- a/ios/config/EnrichedConfig.mm +++ b/ios/config/EnrichedConfig.mm @@ -12,6 +12,7 @@ @implementation EnrichedConfig { UIFont *_monospacedFont; BOOL _primaryFontNeedsRecreation; BOOL _monospacedFontNeedsRecreation; + BOOL _allowFontScaling; NSSet *_mentionIndicators; CGFloat _h1FontSize; BOOL _h1Bold; @@ -67,6 +68,7 @@ - (instancetype)init { _primaryFontNeedsRecreation = YES; _monospacedFontNeedsRecreation = YES; _olMarkerFontNeedsRecreation = YES; + _allowFontScaling = YES; return self; } @@ -80,6 +82,7 @@ - (id)copyWithZone:(NSZone *)zone { copy->_primaryFontFamily = [_primaryFontFamily copy]; copy->_primaryFont = [_primaryFont copy]; copy->_monospacedFont = [_monospacedFont copy]; + copy->_allowFontScaling = _allowFontScaling; copy->_mentionIndicators = [_mentionIndicators copy]; copy->_h1FontSize = _h1FontSize; copy->_h1Bold = _h1Bold; @@ -160,6 +163,9 @@ - (void)setPrimaryLineHeight:(CGFloat)newValue { } - (CGFloat)scaledPrimaryLineHeight { + if (!_allowFontScaling) { + return [self primaryLineHeight]; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:[self primaryLineHeight]]; } @@ -230,6 +236,9 @@ - (void)setMentionIndicators:(NSSet *)newValue { } - (CGFloat)h1FontSize { + if (!_allowFontScaling) { + return _h1FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h1FontSize]; } @@ -246,6 +255,9 @@ - (void)setH1Bold:(BOOL)newValue { } - (CGFloat)h2FontSize { + if (!_allowFontScaling) { + return _h2FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h2FontSize]; } @@ -262,6 +274,9 @@ - (void)setH2Bold:(BOOL)newValue { } - (CGFloat)h3FontSize { + if (!_allowFontScaling) { + return _h3FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h3FontSize]; } @@ -278,6 +293,9 @@ - (void)setH3Bold:(BOOL)newValue { } - (CGFloat)h4FontSize { + if (!_allowFontScaling) { + return _h4FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h4FontSize]; } @@ -294,6 +312,9 @@ - (void)setH4Bold:(BOOL)newValue { } - (CGFloat)h5FontSize { + if (!_allowFontScaling) { + return _h5FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h5FontSize]; } @@ -310,6 +331,9 @@ - (void)setH5Bold:(BOOL)newValue { } - (CGFloat)h6FontSize { + if (!_allowFontScaling) { + return _h6FontSize; + } return [[UIFontMetrics defaultMetrics] scaledValueForValue:_h6FontSize]; } @@ -526,7 +550,21 @@ - (void)invalidateFonts { _olMarkerFontNeedsRecreation = YES; } +- (BOOL)allowFontScaling { + return _allowFontScaling; +} + +- (void)setAllowFontScaling:(BOOL)newValue { + if (_allowFontScaling != newValue) { + _allowFontScaling = newValue; + [self invalidateFonts]; + } +} + - (NSNumber *)scaledPrimaryFontSize { + if (!_allowFontScaling) { + return [self primaryFontSize]; + } CGFloat scaledSize = [[UIFontMetrics defaultMetrics] scaledValueForValue:[[self primaryFontSize] floatValue]]; return @(scaledSize); diff --git a/src/native/EnrichedText.tsx b/src/native/EnrichedText.tsx index e472f8c21..ec24debfd 100644 --- a/src/native/EnrichedText.tsx +++ b/src/native/EnrichedText.tsx @@ -34,6 +34,7 @@ export const EnrichedText = ({ numberOfLines = 0, selectable = false, selectionColor, + allowFontScaling = true, onLinkPress: _onLinkPress, onMentionPress: _onMentionPress, ...rest @@ -105,6 +106,7 @@ export const EnrichedText = ({ numberOfLines={numberOfLines} selectable={selectable} selectionColor={selectionColor} + allowFontScaling={allowFontScaling} onLinkPress={onLinkPress} onMentionPress={onMentionPress} {...rest} diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index 51e68fa14..ba31ba978 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -80,6 +80,7 @@ export const EnrichedTextInput = ({ androidExperimentalSynchronousEvents = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.androidExperimentalSynchronousEvents, useHtmlNormalizer = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.useHtmlNormalizer, scrollEnabled = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.scrollEnabled, + allowFontScaling = ENRICHED_TEXT_INPUT_DEFAULT_PROPS.allowFontScaling, ...rest }: EnrichedTextInputProps) => { const nativeRef = useRef(null); @@ -364,6 +365,7 @@ export const EnrichedTextInput = ({ } useHtmlNormalizer={useHtmlNormalizer} scrollEnabled={scrollEnabled} + allowFontScaling={allowFontScaling} {...rest} /> ); diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index b344aff64..2eb29ded1 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -370,6 +370,7 @@ export interface NativeProps extends ViewProps { returnKeyType?: string; returnKeyLabel?: string; submitBehavior?: string; + allowFontScaling?: boolean; // event callbacks onInputFocus?: DirectEventHandler; diff --git a/src/spec/EnrichedTextNativeComponent.ts b/src/spec/EnrichedTextNativeComponent.ts index a47fc4438..fec62bd3d 100644 --- a/src/spec/EnrichedTextNativeComponent.ts +++ b/src/spec/EnrichedTextNativeComponent.ts @@ -84,6 +84,7 @@ export interface NativeProps extends ViewProps { text: string; htmlStyle?: EnrichedTextHtmlStyleInternal; useHtmlNormalizer: boolean; + allowFontScaling?: boolean; // ReactNative TextProps ellipsizeMode: string; diff --git a/src/types.ts b/src/types.ts index fffd83c9f..33ed31f64 100644 --- a/src/types.ts +++ b/src/types.ts @@ -498,6 +498,11 @@ export interface EnrichedTextInputProps extends Omit { * Disabled by default. */ useHtmlNormalizer?: boolean; + /** + * If true, fonts will scale to respect the system's accessibility text size. + * Enabled by default. + */ + allowFontScaling?: boolean; } export interface EnrichedTextInstance extends NativeMethods {} @@ -512,6 +517,11 @@ export interface EnrichedTextProps extends ViewProps { numberOfLines?: number; selectable?: boolean; selectionColor?: ColorValue; + /** + * If true, fonts will scale to respect the system's accessibility text size. + * Enabled by default. + */ + allowFontScaling?: boolean; onLinkPress?: (event: OnLinkPressEvent) => void; onMentionPress?: (event: OnMentionPressEvent) => void; } diff --git a/src/utils/EnrichedTextInputDefaultProps.ts b/src/utils/EnrichedTextInputDefaultProps.ts index e34e2f4b1..ac41ade56 100644 --- a/src/utils/EnrichedTextInputDefaultProps.ts +++ b/src/utils/EnrichedTextInputDefaultProps.ts @@ -6,4 +6,5 @@ export const ENRICHED_TEXT_INPUT_DEFAULT_PROPS = { scrollEnabled: true, androidExperimentalSynchronousEvents: false, useHtmlNormalizer: false, + allowFontScaling: true, } as const;