Skip to content

Commit

Permalink
feat: selectionHandleColor prop on Android (#41092)
Browse files Browse the repository at this point in the history
Summary:
This PR addresses the problem raised in the #41004 issue.

The current logic is that `selectionColor` on iOS sets the color of the selection, handles, and cursor. On Android it looks similar, while it doesn't change the color of the handles if the API level is higher than 27. In addition, on Android there was an option to set the color of the cursor by `cursorColor` prop, but it didn't work if the `selectionCursor` was set.

## Changelog:

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

[ANDROID|GENERAL|IOS|INTERNAL] [BREAKING|ADDED|CHANGED|DEPRECATED|REMOVED|FIXED|SECURITY] - Message

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests
-->

[GENERAL] [ADDED] - Make same behavior of the `selectionColor` prop on Android as iOS
[ANDROID] [ADDED] - Introduced `selectionHandleColor` as a separate prop
[ANDROID] [CHANGED] - Allowing `cursorColor` and `selectionHandleColor` to override `selectionColor` on Android

Pull Request resolved: #41092

Test Plan:
Manual tests in rn-tester:

### `selectionColor` same as iOS, sets selection, handles and cursor color

_There is a way to set only "rectangle" color by setting other props as null_

![image](https://github.com/facebook/react-native/assets/39670088/9cba34c2-c9fc-4d84-a9cb-3b28a754671d)

### `selectionHandleColor`

![image](https://github.com/facebook/react-native/assets/39670088/8a7e488e-0e35-4646-9efe-4783420b41fa)

### `cursorColor`

![image](https://github.com/facebook/react-native/assets/39670088/06798b8a-851f-44c7-979e-a4e74681b29a)

Reviewed By: NickGerleman

Differential Revision: D51253298

Pulled By: javache

fbshipit-source-id: 290284aa38c6ba0aa6998b937258788ce6376431
  • Loading branch information
jakex7 authored and facebook-github-bot committed Nov 23, 2023
1 parent 6b89dc1 commit 1e68e48
Show file tree
Hide file tree
Showing 10 changed files with 161 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,11 @@ export type NativeProps = $ReadOnly<{|
*/
selectionColor?: ?ColorValue,

/**
* The text selection handle color.
*/
selectionHandleColor?: ?ColorValue,

/**
* The start and end of the text input's selection. Set start and end to
* the same value to position the cursor.
Expand Down Expand Up @@ -692,6 +697,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
fontStyle: true,
textShadowOffset: true,
selectionColor: {process: require('../../StyleSheet/processColor').default},
selectionHandleColor: {
process: require('../../StyleSheet/processColor').default,
},
placeholderTextColor: {
process: require('../../StyleSheet/processColor').default,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,14 @@ export interface TextInputAndroidProps {
*/
cursorColor?: ColorValue | null | undefined;

/**
* When provided it will set the color of the selection handles when highlighting text.
* Unlike the behavior of `selectionColor` the handle color will be set independently
* from the color of the text selection box.
* @platform android
*/
selectionHandleColor?: ColorValue | null | undefined;

/**
* Determines whether the individual fields in your app should be included in a
* view structure for autofill purposes on Android API Level 26+. Defaults to auto.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,14 @@ type AndroidProps = $ReadOnly<{|
*/
cursorColor?: ?ColorValue,

/**
* When provided it will set the color of the selection handles when highlighting text.
* Unlike the behavior of `selectionColor` the handle color will be set independently
* from the color of the text selection box.
* @platform android
*/
selectionHandleColor?: ?ColorValue,

/**
* When `false`, if there is a small amount of space available around a text input
* (e.g. landscape orientation on a phone), the OS may choose to have the user edit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,12 @@ export type Props = $ReadOnly<{|
*/
selectionColor?: ?ColorValue,

/**
* The text selection handle color.
* @platform android
*/
selectionHandleColor?: ?ColorValue,

/**
* If `true`, all text will automatically be selected on focus.
*/
Expand Down Expand Up @@ -1111,6 +1117,9 @@ function InternalTextInput(props: Props): React.Node {
id,
tabIndex,
selection: propsSelection,
selectionColor,
selectionHandleColor,
cursorColor,
...otherProps
} = props;

Expand Down Expand Up @@ -1506,7 +1515,15 @@ function InternalTextInput(props: Props): React.Node {
if (childCount > 1) {
children = <Text>{children}</Text>;
}

// For consistency with iOS set cursor/selectionHandle color as selectionColor
const colorProps = {
selectionColor,
selectionHandleColor:
selectionHandleColor === undefined
? selectionColor
: selectionHandleColor,
cursorColor: cursorColor === undefined ? selectionColor : cursorColor,
};
textInput = (
/* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match up
* exactly with the props for TextInput. This will need to get fixed */
Expand All @@ -1520,6 +1537,7 @@ function InternalTextInput(props: Props): React.Node {
// $FlowFixMe[incompatible-type] - Figure out imperative + forward refs.
ref={ref}
{...otherProps}
{...colorProps}
{...eventHandlers}
accessibilityState={_accessibilityState}
accessibilityLabelledBy={_accessibilityLabelledBy}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,11 @@ public class ReactTextInputManager extends BaseViewManager<ReactEditText, Layout
private static final String KEYBOARD_TYPE_URI = "url";
private static final InputFilter[] EMPTY_FILTERS = new InputFilter[0];
private static final int UNSET = -1;
private static final String[] DRAWABLE_FIELDS = {
"mCursorDrawable", "mSelectHandleLeft", "mSelectHandleRight", "mSelectHandleCenter"
private static final String[] DRAWABLE_HANDLE_RESOURCES = {
"mTextSelectHandleLeftRes", "mTextSelectHandleRightRes", "mTextSelectHandleRes"
};
private static final String[] DRAWABLE_RESOURCES = {
"mCursorDrawableRes",
"mTextSelectHandleLeftRes",
"mTextSelectHandleRightRes",
"mTextSelectHandleRes"
private static final String[] DRAWABLE_HANDLE_FIELDS = {
"mSelectHandleLeft", "mSelectHandleRight", "mSelectHandleCenter"
};

protected @Nullable ReactTextViewManagerCallback mReactTextViewManagerCallback;
Expand Down Expand Up @@ -524,70 +521,124 @@ public void setSelectionColor(ReactEditText view, @Nullable Integer color) {
} else {
view.setHighlightColor(color);
}

setCursorColor(view, color);
}

@ReactProp(name = "cursorColor", customType = "Color")
public void setCursorColor(ReactEditText view, @Nullable Integer color) {
if (color == null) {
return;
}

@ReactProp(name = "selectionHandleColor", customType = "Color")
public void setSelectionHandleColor(ReactEditText view, @Nullable Integer color) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Drawable cursorDrawable = view.getTextCursorDrawable();
if (cursorDrawable != null) {
cursorDrawable.setColorFilter(new BlendModeColorFilter(color, BlendMode.SRC_IN));
view.setTextCursorDrawable(cursorDrawable);
Drawable drawableCenter = view.getTextSelectHandle().mutate();
Drawable drawableLeft = view.getTextSelectHandleLeft().mutate();
Drawable drawableRight = view.getTextSelectHandleRight().mutate();
if (color != null) {
BlendModeColorFilter filter = new BlendModeColorFilter(color, BlendMode.SRC_IN);
drawableCenter.setColorFilter(filter);
drawableLeft.setColorFilter(filter);
drawableRight.setColorFilter(filter);
} else {
drawableCenter.clearColorFilter();
drawableLeft.clearColorFilter();
drawableRight.clearColorFilter();
}
view.setTextSelectHandle(drawableCenter);
view.setTextSelectHandleLeft(drawableLeft);
view.setTextSelectHandleRight(drawableRight);
return;
}

// Based on https://github.com/facebook/react-native/pull/31007
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// Pre-Android 10, there was no supported API to change the cursor color programmatically.
// In Android 9.0, they changed the underlying implementation,
// but also "dark greylisted" the new field, rendering it unusable.
return;
}

// The evil code that follows uses reflection to achieve this on Android 8.1 and below.
// Based on https://tinyurl.com/3vff8lyu https://tinyurl.com/vehggzs9
for (int i = 0; i < DRAWABLE_RESOURCES.length; i++) {
// The following code uses reflection to change handles color on Android 8.1 and below.
for (int i = 0; i < DRAWABLE_HANDLE_RESOURCES.length; i++) {
try {
Field drawableResourceField = TextView.class.getDeclaredField(DRAWABLE_RESOURCES[i]);
Field drawableResourceField =
view.getClass().getDeclaredField(DRAWABLE_HANDLE_RESOURCES[i]);
drawableResourceField.setAccessible(true);
int resourceId = drawableResourceField.getInt(view);

// The view has no cursor drawable.
// The view has no handle drawable.
if (resourceId == 0) {
return;
}

Drawable drawable = ContextCompat.getDrawable(view.getContext(), resourceId);

Drawable drawableCopy = drawable.mutate();
drawableCopy.setColorFilter(color, PorterDuff.Mode.SRC_IN);
Drawable drawable = ContextCompat.getDrawable(view.getContext(), resourceId).mutate();
if (color != null) {
drawable.setColorFilter(color, PorterDuff.Mode.SRC_IN);
} else {
drawable.clearColorFilter();
}

Field editorField = TextView.class.getDeclaredField("mEditor");
editorField.setAccessible(true);
Object editor = editorField.get(view);

Field cursorDrawableField = editor.getClass().getDeclaredField(DRAWABLE_FIELDS[i]);
Field cursorDrawableField = editor.getClass().getDeclaredField(DRAWABLE_HANDLE_FIELDS[i]);
cursorDrawableField.setAccessible(true);
if (DRAWABLE_RESOURCES[i] == "mCursorDrawableRes") {
Drawable[] drawables = {drawableCopy, drawableCopy};
cursorDrawableField.set(editor, drawables);
} else {
cursorDrawableField.set(editor, drawableCopy);
}
cursorDrawableField.set(editor, drawable);
} catch (NoSuchFieldException ex) {
// Ignore errors to avoid crashing if these private fields don't exist on modified
// or future android versions.
} catch (IllegalAccessException ex) {
}
}
}

@ReactProp(name = "cursorColor", customType = "Color")
public void setCursorColor(ReactEditText view, @Nullable Integer color) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Drawable cursorDrawable = view.getTextCursorDrawable();
if (cursorDrawable != null) {
if (color != null) {
cursorDrawable.setColorFilter(new BlendModeColorFilter(color, BlendMode.SRC_IN));
} else {
cursorDrawable.clearColorFilter();
}
view.setTextCursorDrawable(cursorDrawable);
}
return;
}

if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
// Pre-Android 10, there was no supported API to change the cursor color programmatically.
// In Android 9.0, they changed the underlying implementation,
// but also "dark greylisted" the new field, rendering it unusable.
return;
}

// The evil code that follows uses reflection to achieve this on Android 8.1 and below.
// Based on https://tinyurl.com/3vff8lyu https://tinyurl.com/vehggzs9
try {
Field drawableCursorField = view.getClass().getDeclaredField("mCursorDrawableRes");
drawableCursorField.setAccessible(true);
int resourceId = drawableCursorField.getInt(view);

// The view has no cursor drawable.
if (resourceId == 0) {
return;
}

Drawable drawable = ContextCompat.getDrawable(view.getContext(), resourceId).mutate();
if (color != null) {
drawable.setColorFilter(color, PorterDuff.Mode.SRC_IN);
} else {
drawable.clearColorFilter();
}

Field editorField = TextView.class.getDeclaredField("mEditor");
editorField.setAccessible(true);
Object editor = editorField.get(view);

Field cursorDrawableField = editor.getClass().getDeclaredField("mCursorDrawable");
cursorDrawableField.setAccessible(true);
Drawable[] drawables = {drawable, drawable};
cursorDrawableField.set(editor, drawables);
} catch (NoSuchFieldException ex) {
// Ignore errors to avoid crashing if these private fields don't exist on modified
// or future android versions.
} catch (IllegalAccessException ex) {
}
}

private static boolean shouldHideCursorForEmailTextInput() {
String manufacturer = Build.MANUFACTURER.toLowerCase(Locale.ROOT);
return (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && manufacturer.contains("xiaomi"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ AndroidTextInputProps::AndroidTextInputProps(
"selectionColor",
sourceProps.selectionColor,
{})),
selectionHandleColor(CoreFeatures::enablePropIteratorSetter? sourceProps.selectionHandleColor : convertRawProp(context, rawProps,
"selectionHandleColor",
sourceProps.selectionHandleColor,
{})),
value(CoreFeatures::enablePropIteratorSetter? sourceProps.value : convertRawProp(context, rawProps, "value", sourceProps.value, {})),
defaultValue(CoreFeatures::enablePropIteratorSetter? sourceProps.defaultValue : convertRawProp(context, rawProps,
"defaultValue",
Expand Down Expand Up @@ -347,6 +351,7 @@ void AndroidTextInputProps::setProp(
RAW_SET_PROP_SWITCH_CASE_BASIC(placeholderTextColor);
RAW_SET_PROP_SWITCH_CASE_BASIC(secureTextEntry);
RAW_SET_PROP_SWITCH_CASE_BASIC(selectionColor);
RAW_SET_PROP_SWITCH_CASE_BASIC(selectionHandleColor);
RAW_SET_PROP_SWITCH_CASE_BASIC(defaultValue);
RAW_SET_PROP_SWITCH_CASE_BASIC(selectTextOnFocus);
RAW_SET_PROP_SWITCH_CASE_BASIC(submitBehavior);
Expand Down Expand Up @@ -446,6 +451,7 @@ folly::dynamic AndroidTextInputProps::getDynamic() const {
props["placeholderTextColor"] = toAndroidRepr(placeholderTextColor);
props["secureTextEntry"] = secureTextEntry;
props["selectionColor"] = toAndroidRepr(selectionColor);
props["selectionHandleColor"] = toAndroidRepr(selectionHandleColor);
props["value"] = value;
props["defaultValue"] = defaultValue;
props["selectTextOnFocus"] = selectTextOnFocus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class AndroidTextInputProps final : public ViewProps, public BaseTextProps {
SharedColor placeholderTextColor{};
bool secureTextEntry{false};
SharedColor selectionColor{};
SharedColor selectionHandleColor{};
std::string value{};
std::string defaultValue{};
bool selectTextOnFocus{false};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ TextInputProps::TextInputProps(
"selectionColor",
sourceProps.selectionColor,
{})),
selectionHandleColor(convertRawProp(
context,
rawProps,
"selectionHandleColor",
sourceProps.selectionHandleColor,
{})),
underlineColorAndroid(convertRawProp(
context,
rawProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class TextInputProps final : public ViewProps, public BaseTextProps {
*/
const SharedColor cursorColor{};
const SharedColor selectionColor{};
const SharedColor selectionHandleColor{};
// TODO: Rename to `tintColor` and make universal.
const SharedColor underlineColorAndroid{};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,20 @@ const examples: Array<RNTesterModuleExample> = [
</Text>
</TextInput>
<TextInput
defaultValue="Highlight Color is red"
defaultValue="Selection Color is red"
selectionColor={'red'}
style={styles.singleLine}
/>
<TextInput
defaultValue="Selection handles are red"
selectionHandleColor={'red'}
style={styles.singleLine}
/>
<TextInput
defaultValue="Cursor Color is red"
cursorColor={'red'}
style={styles.singleLine}
/>
</View>
);
},
Expand Down Expand Up @@ -470,7 +480,7 @@ const examples: Array<RNTesterModuleExample> = [
'next',
];
const returnKeyLabels = ['Compile', 'React Native'];
const examples = returnKeyTypes.map(type => {
const returnKeyExamples = returnKeyTypes.map(type => {
return (
<TextInput
key={type}
Expand All @@ -492,7 +502,7 @@ const examples: Array<RNTesterModuleExample> = [
});
return (
<View>
{examples}
{returnKeyExamples}
{types}
</View>
);
Expand Down

0 comments on commit 1e68e48

Please sign in to comment.