feat(core): textbase span interaction and styling improvements (#10682)

- Added `linkTap` event support for other iOS views that nest spans
- Prevent android span from setting parent background color to itself since it doesn't react to changes of that property. Unless background color is specified to the span directly, it's going to be transparent
- Added few missing `nativeTextViewProtected` references
- Improved view disposal for classes that inherit from TextBase as they had leftovers after android activity recreation
- Removed 2 assignments of `userInteractionEnabled` from TextBase as they were unneeded and had conflicts with `isUserInteractionEnabled` property. Core already sets that property to true for the views that need it in `createNativeView` call
- `HTMLView` will remove extra padding using the documented `UIEdgeInsetsZero`
This commit is contained in:
Dimitris-Rafail Katsampas
2025-02-01 00:00:52 +02:00
committed by GitHub
parent 03cca58712
commit 966dccd0f9
10 changed files with 113 additions and 64 deletions

View File

@ -84,12 +84,10 @@ function initializeEditTextListeners(): void {
}
export abstract class EditableTextBase extends EditableTextBaseCommon {
/* tslint:disable */
_dirtyTextAccumulator: string;
/* tslint:enable */
nativeViewProtected: android.widget.EditText;
nativeTextViewProtected: android.widget.EditText;
private _dirtyTextAccumulator: string;
private _keyListenerCache: android.text.method.KeyListener;
private _inputType: number;
@ -120,12 +118,16 @@ export abstract class EditableTextBase extends EditableTextBaseCommon {
public disposeNativeView(): void {
const editText = this.nativeTextViewProtected;
editText.removeTextChangedListener((<any>editText).listener);
editText.setOnFocusChangeListener(null);
editText.setOnEditorActionListener(null);
(<any>editText).listener.owner = null;
(<any>editText).listener = null;
this._keyListenerCache = null;
this._dirtyTextAccumulator = undefined;
this._inputType = 0;
super.disposeNativeView();
}

View File

@ -29,7 +29,7 @@ export class HtmlView extends HtmlViewBase {
// Remove extra padding
this.nativeViewProtected.textContainer.lineFragmentPadding = 0;
this.nativeViewProtected.textContainerInset = (UIEdgeInsets as any).zero;
this.nativeViewProtected.textContainerInset = UIEdgeInsetsZero;
}
// @ts-ignore

View File

@ -19,6 +19,8 @@ enum FixedSize {
@CSSType('Label')
export class Label extends TextBase implements LabelDefinition {
nativeViewProtected: TNSLabel;
nativeTextViewProtected: TNSLabel;
private _fixedSize: FixedSize;
public createNativeView() {
@ -28,6 +30,11 @@ export class Label extends TextBase implements LabelDefinition {
return view;
}
public disposeNativeView(): void {
super.disposeNativeView();
this._fixedSize = null;
}
// @ts-ignore
get ios(): TNSLabel {
return this.nativeTextViewProtected;
@ -102,7 +109,7 @@ export class Label extends TextBase implements LabelDefinition {
}
private _measureNativeView(width: number, widthMode: number, height: number, heightMode: number): { width: number; height: number } {
const view = <UILabel>this.nativeTextViewProtected;
const view = this.nativeTextViewProtected;
const nativeSize = view.textRectForBoundsLimitedToNumberOfLines(CGRectMake(0, 0, widthMode === 0 /* layout.UNSPECIFIED */ ? Number.POSITIVE_INFINITY : layout.toDeviceIndependentPixels(width), heightMode === 0 /* layout.UNSPECIFIED */ ? Number.POSITIVE_INFINITY : layout.toDeviceIndependentPixels(height)), view.numberOfLines).size;
@ -123,7 +130,8 @@ export class Label extends TextBase implements LabelDefinition {
private adjustLineBreak() {
const whiteSpace = this.whiteSpace;
const textOverflow = this.textOverflow;
const nativeView = this.nativeViewProtected;
const nativeView = this.nativeTextViewProtected;
switch (whiteSpace) {
case 'normal':
nativeView.lineBreakMode = NSLineBreakMode.ByWordWrapping;
@ -158,7 +166,7 @@ export class Label extends TextBase implements LabelDefinition {
const cgColor = color ? color.CGColor : null;
nativeView.layer.backgroundColor = cgColor;
},
true
true,
);
}
}

View File

@ -9,7 +9,6 @@ export class ScrollView extends ScrollViewBase {
nativeViewProtected: org.nativescript.widgets.VerticalScrollView | org.nativescript.widgets.HorizontalScrollView;
private _androidViewId = -1;
private handler: android.view.ViewTreeObserver.OnScrollChangedListener;
private scrollChangeHandler: androidx.core.widget.NestedScrollView.OnScrollChangeListener;
get horizontalOffset(): number {
const nativeView = this.nativeViewProtected;

View File

@ -73,7 +73,7 @@ class UISearchBarImpl extends UISearchBar {
export class SearchBar extends SearchBarBase {
nativeViewProtected: UISearchBar;
private _delegate;
private __textField: UITextField;
private _textField: UITextField;
createNativeView() {
return UISearchBarImpl.new();
@ -87,6 +87,7 @@ export class SearchBar extends SearchBarBase {
disposeNativeView() {
this._delegate = null;
this._textField = null;
super.disposeNativeView();
}
@ -94,26 +95,26 @@ export class SearchBar extends SearchBarBase {
(<UIResponder>this.ios).resignFirstResponder();
}
private _getTextField(): UITextField {
if (!this._textField) {
this._textField = this.ios.valueForKey('searchField');
}
return this._textField;
}
// @ts-ignore
get ios(): UISearchBar {
return this.nativeViewProtected;
}
get _textField(): UITextField {
if (!this.__textField) {
this.__textField = this.ios.valueForKey('searchField');
}
return this.__textField;
}
[isEnabledProperty.setNative](value: boolean) {
const nativeView = this.nativeViewProtected;
if (nativeView instanceof UIControl) {
nativeView.enabled = value;
}
const textField = this._textField;
const textField = this._getTextField();
if (textField) {
textField.enabled = value;
}
@ -128,7 +129,7 @@ export class SearchBar extends SearchBarBase {
}
[colorProperty.getDefault](): UIColor {
const sf = this._textField;
const sf = this._getTextField();
if (sf) {
return sf.textColor;
}
@ -136,7 +137,7 @@ export class SearchBar extends SearchBarBase {
return null;
}
[colorProperty.setNative](value: UIColor | Color) {
const sf = this._textField;
const sf = this._getTextField();
const color = value instanceof Color ? value.ios : value;
if (sf) {
sf.textColor = color;
@ -145,12 +146,12 @@ export class SearchBar extends SearchBarBase {
}
[fontInternalProperty.getDefault](): UIFont {
const sf = this._textField;
const sf = this._getTextField();
return sf ? sf.font : null;
}
[fontInternalProperty.setNative](value: UIFont | Font) {
const sf = this._textField;
const sf = this._getTextField();
if (sf) {
sf.font = value instanceof Font ? value.getUIFont(sf.font) : value;
}
@ -179,7 +180,7 @@ export class SearchBar extends SearchBarBase {
}
[textFieldBackgroundColorProperty.getDefault](): UIColor {
const textField = this._textField;
const textField = this._getTextField();
if (textField) {
return textField.backgroundColor;
}
@ -188,7 +189,7 @@ export class SearchBar extends SearchBarBase {
}
[textFieldBackgroundColorProperty.setNative](value: Color | UIColor) {
const color = value instanceof Color ? value.ios : value;
const textField = this._textField;
const textField = this._getTextField();
if (textField) {
textField.backgroundColor = color;
}
@ -219,6 +220,6 @@ export class SearchBar extends SearchBarBase {
attributes[NSForegroundColorAttributeName] = this.textFieldHintColor.ios;
}
const attributedPlaceholder = NSAttributedString.alloc().initWithStringAttributes(stringValue, attributes);
this._textField.attributedPlaceholder = attributedPlaceholder;
this._getTextField().attributedPlaceholder = attributedPlaceholder;
}
}

View File

@ -23,7 +23,7 @@ export * from './text-base-common';
let TextTransformation: TextTransformation;
export interface TextTransformation {
new (owner: TextBase): any /* android.text.method.TransformationMethod */;
new (owner: TextBase): android.text.method.TransformationMethod;
}
function initializeTextTransformation(): void {
@ -169,9 +169,8 @@ function initializeBaselineAdjustedSpan(): void {
}
export class TextBase extends TextBaseCommon {
nativeViewProtected: org.nativescript.widgets.StyleableTextView;
// @ts-ignore
nativeTextViewProtected: org.nativescript.widgets.StyleableTextView;
public nativeViewProtected: org.nativescript.widgets.StyleableTextView;
private _defaultTransformationMethod: android.text.method.TransformationMethod;
private _paintFlags: number;
private _minHeight: number;
@ -181,6 +180,10 @@ export class TextBase extends TextBaseCommon {
private _tappable = false;
private _defaultMovementMethod: android.text.method.MovementMethod;
get nativeTextViewProtected(): org.nativescript.widgets.StyleableTextView {
return super.nativeTextViewProtected;
}
public initNativeView(): void {
super.initNativeView();
initializeTextTransformation();
@ -193,6 +196,19 @@ export class TextBase extends TextBaseCommon {
this._maxLines = nativeView.getMaxLines();
}
public disposeNativeView(): void {
super.disposeNativeView();
this._tappable = false;
this._defaultTransformationMethod = null;
this._defaultMovementMethod = null;
this._paintFlags = 0;
this._minHeight = 0;
this._maxHeight = 0;
this._minLines = 0;
this._maxLines = 0;
}
public resetNativeView(): void {
super.resetNativeView();
const nativeView = this.nativeTextViewProtected;
@ -502,13 +518,13 @@ export class TextBase extends TextBaseCommon {
}
if (this.style?.textStroke) {
this.nativeViewProtected.setTextStroke(Length.toDevicePixels(this.style.textStroke.width), this.style.textStroke.color.android, this.style.color.android);
} else if (this.nativeViewProtected.setTextStroke) {
this.nativeTextViewProtected.setTextStroke(Length.toDevicePixels(this.style.textStroke.width), this.style.textStroke.color.android, this.style.color.android);
} else if (this.nativeTextViewProtected.setTextStroke) {
// reset
this.nativeViewProtected.setTextStroke(0, 0, 0);
this.nativeTextViewProtected.setTextStroke(0, 0, 0);
}
this.nativeTextViewProtected.setText(<any>transformedText);
this.nativeTextViewProtected.setText(transformedText);
}
_setTappableState(tappable: boolean) {
@ -608,10 +624,8 @@ function setSpanModifiers(ssb: android.text.SpannableStringBuilder, span: Span,
ssb.setSpan(new android.text.style.ForegroundColorSpan(color.android), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
const backgroundColor: Color = getClosestPropertyValue(<any>backgroundColorProperty, span);
if (backgroundColor) {
ssb.setSpan(new android.text.style.BackgroundColorSpan(backgroundColor.android), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (spanStyle.backgroundColor) {
ssb.setSpan(new android.text.style.BackgroundColorSpan(spanStyle.backgroundColor.android), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
const textDecoration: CoreTypes.TextDecorationType = getClosestPropertyValue(textDecorationProperty, span);

View File

@ -34,7 +34,7 @@ class UILabelClickHandlerImpl extends NSObject {
public linkTap(tapGesture: UITapGestureRecognizer) {
const owner = this._owner?.deref();
if (owner) {
const label = <UILabel>owner.nativeTextViewProtected;
const nativeView = owner.nativeTextViewProtected instanceof UIButton ? owner.nativeTextViewProtected.titleLabel : owner.nativeTextViewProtected;
// This offset along with setting paragraph style alignment will achieve perfect horizontal alignment for NSTextContainer
let offsetXMultiplier: number;
@ -53,18 +53,27 @@ class UILabelClickHandlerImpl extends NSObject {
const layoutManager = NSLayoutManager.alloc().init();
const textContainer = NSTextContainer.alloc().initWithSize(CGSizeZero);
const textStorage = NSTextStorage.alloc().initWithAttributedString(owner.nativeTextViewProtected['attributedText']);
const textStorage = NSTextStorage.alloc().initWithAttributedString(nativeView.attributedText);
layoutManager.addTextContainer(textContainer);
textStorage.addLayoutManager(layoutManager);
textContainer.lineFragmentPadding = 0;
textContainer.lineBreakMode = label.lineBreakMode;
textContainer.maximumNumberOfLines = label.numberOfLines;
const labelSize = label.bounds.size;
if (nativeView instanceof UITextView) {
textContainer.lineBreakMode = nativeView.textContainer.lineBreakMode;
textContainer.maximumNumberOfLines = nativeView.textContainer.maximumNumberOfLines;
} else {
if (!(nativeView instanceof UITextField)) {
textContainer.lineBreakMode = nativeView.lineBreakMode;
textContainer.maximumNumberOfLines = nativeView.numberOfLines;
}
}
const labelSize = nativeView.bounds.size;
textContainer.size = labelSize;
const locationOfTouchInLabel = tapGesture.locationInView(label);
const locationOfTouchInLabel = tapGesture.locationInView(nativeView);
const textBoundingBox = layoutManager.usedRectForTextContainer(textContainer);
const textContainerOffset = CGPointMake((labelSize.width - textBoundingBox.size.width) * offsetXMultiplier - textBoundingBox.origin.x, (labelSize.height - textBoundingBox.size.height) * offsetYMultiplier - textBoundingBox.origin.y);
@ -117,31 +126,46 @@ class UILabelClickHandlerImpl extends NSObject {
export class TextBase extends TextBaseCommon {
public nativeViewProtected: UITextField | UITextView | UILabel | UIButton;
// @ts-ignore
public nativeTextViewProtected: UITextField | UITextView | UILabel | UIButton;
private _tappable = false;
private _tapGestureRecognizer: UITapGestureRecognizer;
public _spanRanges: NSRange[];
private _tappable = false;
private _linkTapHandler: UILabelClickHandlerImpl;
private _tapGestureRecognizer: UITapGestureRecognizer;
get nativeTextViewProtected(): UITextField | UITextView | UILabel | UIButton {
return super.nativeTextViewProtected;
}
public initNativeView(): void {
super.initNativeView();
this._setTappableState(false);
}
public disposeNativeView(): void {
super.disposeNativeView();
this._tappable = false;
this._linkTapHandler = null;
this._tapGestureRecognizer = null;
}
_setTappableState(tappable: boolean) {
if (this._tappable !== tappable) {
const nativeTextView = this.nativeTextViewProtected;
this._tappable = tappable;
if (this._tappable) {
const tapHandler = UILabelClickHandlerImpl.initWithOwner(new WeakRef(this));
// associate handler with menuItem or it will get collected by JSC.
(<any>this).handler = tapHandler;
// Associate handler with menuItem or it will get collected by JSC
this._linkTapHandler = tapHandler;
this._tapGestureRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(tapHandler, 'linkTap');
this.nativeViewProtected.userInteractionEnabled = true;
this.nativeViewProtected.addGestureRecognizer(this._tapGestureRecognizer);
this._tapGestureRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(this._linkTapHandler, 'linkTap');
nativeTextView.addGestureRecognizer(this._tapGestureRecognizer);
} else {
this.nativeViewProtected.userInteractionEnabled = false;
this.nativeViewProtected.removeGestureRecognizer(this._tapGestureRecognizer);
nativeTextView.removeGestureRecognizer(this._tapGestureRecognizer);
this._linkTapHandler = null;
this._tapGestureRecognizer = null;
}
}
}

View File

@ -12,7 +12,6 @@ import { Style } from '../styling/style';
import { Observable } from '../../data/observable';
import { CoreTypes } from '../../core-types';
import { TextBase as TextBaseDefinition } from '.';
import { Color } from '../../color';
import { ShadowCSSValues, parseCSSShadow } from '../styling/css-shadow';
import { StrokeCSSValues, parseCSSStroke } from '../styling/css-stroke';

View File

@ -114,7 +114,9 @@ class UITextFieldImpl extends UITextField {
export class TextField extends TextFieldBase {
nativeViewProtected: UITextField;
private _delegate: UITextFieldDelegateImpl;
private _firstEdit: boolean;
createNativeView() {
return UITextFieldImpl.initWithOwner(new WeakRef(this));
@ -128,6 +130,7 @@ export class TextField extends TextFieldBase {
disposeNativeView() {
this._delegate = null;
this._firstEdit = false;
super.disposeNativeView();
}
@ -136,10 +139,8 @@ export class TextField extends TextFieldBase {
return this.nativeViewProtected;
}
private firstEdit: boolean;
public textFieldShouldBeginEditing(textField: UITextField): boolean {
this.firstEdit = true;
this._firstEdit = true;
return this.editable;
}
@ -157,7 +158,7 @@ export class TextField extends TextFieldBase {
}
public textFieldShouldClear(textField: UITextField) {
this.firstEdit = false;
this._firstEdit = false;
textProperty.nativeValueChange(this, '');
return true;
@ -192,7 +193,7 @@ export class TextField extends TextFieldBase {
if (this.updateTextTrigger === 'textChanged') {
if (this.valueFormatter) {
// format/replace
let currentValue = textField.text;
const currentValue = textField.text;
let nativeValueChange = `${textField.text}${replacementString}`;
if (replacementString === '') {
// clearing when empty
@ -207,7 +208,7 @@ export class TextField extends TextFieldBase {
// 1. secureTextEntry with firstEdit should not replace
// 2. emoji's should not replace value
// 3. convenient keyboard shortcuts should not replace value (eg, '.com')
const shouldReplaceString = (textField.secureTextEntry && this.firstEdit) || (delta > 1 && !isEmoji(replacementString) && delta !== replacementString.length);
const shouldReplaceString = (textField.secureTextEntry && this._firstEdit) || (delta > 1 && !isEmoji(replacementString) && delta !== replacementString.length);
if (shouldReplaceString) {
textProperty.nativeValueChange(this, replacementString);
} else {
@ -226,7 +227,7 @@ export class TextField extends TextFieldBase {
// if the textfield is in auto size we need to request a layout to take the new text width into account
this.requestLayout();
}
this.firstEdit = false;
this._firstEdit = false;
return true;
}

View File

@ -88,7 +88,8 @@ export class TextView extends TextViewBaseCommon {
nativeViewProtected: UITextView;
nativeTextViewProtected: UITextView;
private _delegate: UITextViewDelegateImpl;
_isShowingHint: boolean;
private _isShowingHint: boolean;
public _isEditing: boolean;
private _hintColor = majorVersion <= 12 || !UIColor.placeholderTextColor ? UIColor.blackColor.colorWithAlphaComponent(0.22) : UIColor.placeholderTextColor;