From 0804fda4ebda055ee77f2aaeb0878064991e4e55 Mon Sep 17 00:00:00 2001 From: xiazhigang Date: Thu, 9 Apr 2026 14:20:15 +0800 Subject: [PATCH] feat: add maxLength support to enriched input --- .../textinput/EnrichedTextInputView.kt | 35 +++++++++++++++++++ .../textinput/EnrichedTextInputViewManager.kt | 10 ++++++ .../events/OnMaxLengthExceededEvent.kt | 26 ++++++++++++++ ios/EnrichedTextInputView.h | 3 ++ ios/EnrichedTextInputView.mm | 21 +++++++++++ src/index.tsx | 1 + src/native/EnrichedTextInput.tsx | 4 +++ src/spec/EnrichedTextInputNativeComponent.ts | 6 ++++ src/types.ts | 8 +++++ 9 files changed, 114 insertions(+) create mode 100644 android/src/main/java/com/swmansion/enriched/textinput/events/OnMaxLengthExceededEvent.kt 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 42264b4e..f75ff38b 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputView.kt @@ -10,6 +10,7 @@ import android.graphics.Rect import android.graphics.text.LineBreaker import android.os.Build import android.text.Editable +import android.text.InputFilter import android.text.InputType import android.text.Spannable import android.text.SpannableString @@ -46,6 +47,7 @@ import com.swmansion.enriched.textinput.events.MentionHandler import com.swmansion.enriched.textinput.events.OnContextMenuItemPressEvent import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent +import com.swmansion.enriched.textinput.events.OnMaxLengthExceededEvent import com.swmansion.enriched.textinput.events.OnRequestHtmlResultEvent import com.swmansion.enriched.textinput.events.OnSubmitEditingEvent import com.swmansion.enriched.textinput.spans.EnrichedInputH1Span @@ -122,6 +124,7 @@ class EnrichedTextInputView : private var fontWeight: Int = ReactConstants.UNSET private var defaultValue: CharSequence? = null private var defaultValueDirty: Boolean = false + private var maxLength: Int? = null private var inputMethodManager: InputMethodManager? = null private val spannableFactory = EnrichedTextInputSpannableFactory() @@ -338,6 +341,38 @@ class EnrichedTextInputView : } } + fun setMaxLength(value: Int?) { + maxLength = value + filters = + if (value == null) { + emptyArray() + } else { + arrayOf( + InputFilter { source, start, end, dest, dstart, dend -> + val remaining = value - ((dest?.length ?: 0) - (dend - dstart)) + val incomingLength = end - start + + if (incomingLength <= 0 || incomingLength <= remaining) { + return@InputFilter null + } + + emitOnMaxLengthExceededEvent(value) + "" + }, + ) + } + } + + private fun emitOnMaxLengthExceededEvent(limit: Int) { + val context = context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(context) + val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(context, id) + + dispatcher?.dispatchEvent( + OnMaxLengthExceededEvent(surfaceId, id, limit, experimentalSynchronousEvents), + ) + } + fun handleTextPaste(item: ClipData.Item) { val currentText = text as Spannable val start = selectionStart.coerceAtLeast(0) 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 6b24ac27..9844ba53 100644 --- a/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt +++ b/android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputViewManager.kt @@ -24,6 +24,7 @@ import com.swmansion.enriched.textinput.events.OnInputBlurEvent import com.swmansion.enriched.textinput.events.OnInputFocusEvent import com.swmansion.enriched.textinput.events.OnInputKeyPressEvent import com.swmansion.enriched.textinput.events.OnLinkDetectedEvent +import com.swmansion.enriched.textinput.events.OnMaxLengthExceededEvent import com.swmansion.enriched.textinput.events.OnMentionDetectedEvent import com.swmansion.enriched.textinput.events.OnMentionEvent import com.swmansion.enriched.textinput.events.OnPasteImagesEvent @@ -74,6 +75,7 @@ class EnrichedTextInputViewManager : map.put(OnRequestHtmlResultEvent.EVENT_NAME, mapOf("registrationName" to OnRequestHtmlResultEvent.EVENT_NAME)) map.put(OnInputKeyPressEvent.EVENT_NAME, mapOf("registrationName" to OnInputKeyPressEvent.EVENT_NAME)) map.put(OnPasteImagesEvent.EVENT_NAME, mapOf("registrationName" to OnPasteImagesEvent.EVENT_NAME)) + map.put(OnMaxLengthExceededEvent.EVENT_NAME, mapOf("registrationName" to OnMaxLengthExceededEvent.EVENT_NAME)) map.put(OnContextMenuItemPressEvent.EVENT_NAME, mapOf("registrationName" to OnContextMenuItemPressEvent.EVENT_NAME)) map.put(OnSubmitEditingEvent.EVENT_NAME, mapOf("registrationName" to OnSubmitEditingEvent.EVENT_NAME)) @@ -96,6 +98,14 @@ class EnrichedTextInputViewManager : view?.setPlaceholder(value) } + @ReactProp(name = "maxLength", defaultInt = 0) + override fun setMaxLength( + view: EnrichedTextInputView?, + value: Int, + ) { + view?.setMaxLength(value.takeIf { it > 0 }) + } + @ReactProp(name = "placeholderTextColor", customType = "Color") override fun setPlaceholderTextColor( view: EnrichedTextInputView?, diff --git a/android/src/main/java/com/swmansion/enriched/textinput/events/OnMaxLengthExceededEvent.kt b/android/src/main/java/com/swmansion/enriched/textinput/events/OnMaxLengthExceededEvent.kt new file mode 100644 index 00000000..e4857d35 --- /dev/null +++ b/android/src/main/java/com/swmansion/enriched/textinput/events/OnMaxLengthExceededEvent.kt @@ -0,0 +1,26 @@ +package com.swmansion.enriched.textinput.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class OnMaxLengthExceededEvent( + surfaceId: Int, + viewId: Int, + private val maxLength: Int, + private val experimentalSynchronousEvents: Boolean, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val eventData: WritableMap = Arguments.createMap() + eventData.putInt("maxLength", maxLength) + return eventData + } + + override fun experimental_isSynchronous(): Boolean = experimentalSynchronousEvents + + companion object { + const val EVENT_NAME: String = "onMaxLengthExceeded" + } +} diff --git a/ios/EnrichedTextInputView.h b/ios/EnrichedTextInputView.h index 1ea44ad9..e3772cb8 100644 --- a/ios/EnrichedTextInputView.h +++ b/ios/EnrichedTextInputView.h @@ -36,11 +36,14 @@ NS_ASSUME_NONNULL_BEGIN BOOL useHtmlNormalizer; @public NSValue *dotReplacementRange; +@public + NSInteger maxLength; } - (CGSize)measureSize:(CGFloat)maxWidth; - (void)emitOnLinkDetectedEvent:(LinkData *)linkData range:(NSRange)range; - (void)emitOnMentionEvent:(NSString *)indicator text:(nullable NSString *)text; - (void)emitOnPasteImagesEvent:(NSArray *)images; +- (void)emitOnMaxLengthExceededEvent:(NSInteger)maxLength; - (void)anyTextMayHaveBeenModified; - (void)scheduleRelayoutIfNeeded; - (BOOL)handleStyleBlocksAndConflicts:(StyleType)type range:(NSRange)range; diff --git a/ios/EnrichedTextInputView.mm b/ios/EnrichedTextInputView.mm index 66c22512..f8e6a5fc 100644 --- a/ios/EnrichedTextInputView.mm +++ b/ios/EnrichedTextInputView.mm @@ -782,6 +782,10 @@ - (void)updateProps:(Props::Shared const &)props textView.editable = newViewProps.editable; } + if (newViewProps.maxLength != oldViewProps.maxLength) { + maxLength = newViewProps.maxLength; + } + // useHtmlNormalizer if (newViewProps.useHtmlNormalizer != oldViewProps.useHtmlNormalizer) { useHtmlNormalizer = newViewProps.useHtmlNormalizer; @@ -1526,6 +1530,13 @@ - (void)emitOnKeyPressEvent:(NSString *)key { } } +- (void)emitOnMaxLengthExceededEvent:(NSInteger)limit { + auto emitter = [self getEventEmitter]; + if (emitter != nullptr) { + emitter->onMaxLengthExceeded({.maxLength = static_cast(limit)}); + } +} + // MARK: - Styles manipulation - (void)toggleRegularStyle:(StyleType)type { @@ -1994,6 +2005,16 @@ - (void)handleKeyPressInRange:(NSString *)text range:(NSRange)range { - (bool)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text { + if (maxLength > 0) { + NSString *currentText = textView.textStorage.string ?: @""; + NSUInteger proposedLength = currentText.length - range.length + text.length; + + if (proposedLength > maxLength) { + [self emitOnMaxLengthExceededEvent:maxLength]; + return NO; + } + } + // Check if the user pressed "Enter" if ([text isEqualToString:@"\n"]) { const bool shouldSubmit = [self textInputShouldSubmitOnReturn]; diff --git a/src/index.tsx b/src/index.tsx index e5c3fc14..f0fac94f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,6 +9,7 @@ export type { OnChangeSelectionEvent, OnKeyPressEvent, OnPasteImagesEvent, + OnMaxLengthExceededEvent, OnSubmitEditing, HtmlStyle, MentionStyleProperties, diff --git a/src/native/EnrichedTextInput.tsx b/src/native/EnrichedTextInput.tsx index a58756bc..a04990b5 100644 --- a/src/native/EnrichedTextInput.tsx +++ b/src/native/EnrichedTextInput.tsx @@ -73,6 +73,7 @@ export const EnrichedTextInput = ({ onChangeSelection, onKeyPress, onSubmitEditing, + onMaxLengthExceeded, returnKeyType, returnKeyLabel, submitBehavior, @@ -80,6 +81,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, + maxLength, ...rest }: EnrichedTextInputProps) => { const nativeRef = useRef(null); @@ -348,6 +350,7 @@ export const EnrichedTextInput = ({ onChangeSelection={onChangeSelection} onRequestHtmlResult={handleRequestHtmlResult} onInputKeyPress={onKeyPress} + onMaxLengthExceeded={onMaxLengthExceeded} contextMenuItems={nativeContextMenuItems} onContextMenuItemPress={handleContextMenuItemPress} onSubmitEditing={onSubmitEditing} @@ -359,6 +362,7 @@ export const EnrichedTextInput = ({ } useHtmlNormalizer={useHtmlNormalizer} scrollEnabled={scrollEnabled} + maxLength={maxLength} {...rest} /> ); diff --git a/src/spec/EnrichedTextInputNativeComponent.ts b/src/spec/EnrichedTextInputNativeComponent.ts index 83e557d5..2e03629f 100644 --- a/src/spec/EnrichedTextInputNativeComponent.ts +++ b/src/spec/EnrichedTextInputNativeComponent.ts @@ -296,6 +296,10 @@ export interface OnPasteImagesEvent { }[]; } +export interface OnMaxLengthExceededEvent { + maxLength: Int32; +} + type Heading = { fontSize?: Float; bold?: boolean; @@ -356,6 +360,7 @@ export interface NativeProps extends ViewProps { editable?: boolean; defaultValue?: string; placeholder?: string; + maxLength?: Int32; placeholderTextColor?: ColorValue; mentionIndicators: string[]; cursorColor?: ColorValue; @@ -382,6 +387,7 @@ export interface NativeProps extends ViewProps { onRequestHtmlResult?: DirectEventHandler; onInputKeyPress?: DirectEventHandler; onPasteImages?: DirectEventHandler; + onMaxLengthExceeded?: DirectEventHandler; onContextMenuItemPress?: DirectEventHandler; onSubmitEditing?: BubblingEventHandler; diff --git a/src/types.ts b/src/types.ts index ada3ceb6..1348bb67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -374,6 +374,10 @@ export interface OnPasteImagesEvent { }[]; } +export interface OnMaxLengthExceededEvent { + maxLength: number; +} + export interface OnSubmitEditing { text: string; } @@ -445,6 +449,7 @@ export interface EnrichedTextInputProps extends Omit { mentionIndicators?: string[]; defaultValue?: string; placeholder?: string; + maxLength?: number; placeholderTextColor?: ColorValue; cursorColor?: ColorValue; selectionColor?: ColorValue; @@ -470,6 +475,9 @@ export interface EnrichedTextInputProps extends Omit { onKeyPress?: (e: NativeSyntheticEvent) => void; onSubmitEditing?: (e: NativeSyntheticEvent) => void; onPasteImages?: (e: NativeSyntheticEvent) => void; + onMaxLengthExceeded?: ( + e: NativeSyntheticEvent + ) => void; contextMenuItems?: ContextMenuItem[]; /** * If true, Android will use experimental synchronous events.