Skip to content
8 changes: 6 additions & 2 deletions api-reports/NativeScript.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1936,14 +1936,18 @@ export class Span extends ViewBase {

public fontWeight: FontWeight;

public static linkTapEvent: string;

// (undocumented)
_setTextInternal(value: string): void;

public readonly tappable: boolean;

public text: string;

public textDecoration: TextDecoration;
//@endprivate
}
}

// @public
export class StackLayout extends LayoutBase {
Expand Down Expand Up @@ -2422,7 +2426,7 @@ export interface TapGestureEventData extends GestureEventData {
getPointerCount(): number;

getX(): number;

getY(): number;
}

Expand Down
1 change: 1 addition & 0 deletions e2e/ui-tests-app/app/button/main-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function loadExamples() {
examples.set("issue-4287", "button/issue-4287-page");
examples.set("issue-4385", "button/issue-4385-page");
examples.set("highlight-4740", "button/highlight-4740/highlight-4740-page");
examples.set("tappable-span", "button/tappable-span-page");

return examples;
}
11 changes: 10 additions & 1 deletion nativescript-core/ui/text-base/span.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,20 @@ export class Span extends ViewBase {
* Gets or sets the text for the span.
*/
public text: string;
/**
* String value used when hooking to linkTap event.
*/
public static linkTapEvent: string;

/**
* Gets if the span is tappable or not.
*/
public readonly tappable: boolean;

//@private
/**
* @private
*/
_setTextInternal(value: string): void;
//@endprivate
}
}
25 changes: 24 additions & 1 deletion nativescript-core/ui/text-base/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import { Span as SpanDefinition } from "./span";
import { ViewBase } from "../core/view";
import { FontStyle, FontWeight, } from "../styling/font";
import { TextDecoration } from "../text-base";
import { TextDecoration, EventData } from "../text-base";

export class Span extends ViewBase implements SpanDefinition {
static linkTapEvent = "linkTap";
private _text: string;
private _tappable: boolean = false;

get fontFamily(): string {
return this.style.fontFamily;
Expand Down Expand Up @@ -68,7 +70,28 @@ export class Span extends ViewBase implements SpanDefinition {
}
}

get tappable(): boolean {
return this._tappable;
}

addEventListener(arg: string, callback: (data: EventData) => void, thisArg?: any) {
super.addEventListener(arg, callback, thisArg);
this._setTappable(this.hasListeners(Span.linkTapEvent));
}

removeEventListener(arg: string, callback?: any, thisArg?: any) {
super.removeEventListener(arg, callback, thisArg);
this._setTappable(this.hasListeners(Span.linkTapEvent));
}

_setTextInternal(value: string): void {
this._text = value;
}

private _setTappable(value: boolean): void {
if (this._tappable !== value) {
this._tappable = value;
this.notifyPropertyChange("tappable", value);
}
}
}
75 changes: 75 additions & 0 deletions nativescript-core/ui/text-base/text-base.android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,42 @@ function initializeTextTransformation(): void {
TextTransformation = TextTransformationImpl;
}

interface ClickableSpan {
new (owner: Span): android.text.style.ClickableSpan;
}

let ClickableSpan: ClickableSpan;

function initializeClickableSpan(): void {
if (ClickableSpan) {
return;
}

class ClickableSpanImpl extends android.text.style.ClickableSpan {
owner: WeakRef<Span>;

constructor(owner: Span) {
super();
this.owner = new WeakRef(owner);

return global.__native(this);
}
onClick(view: android.view.View): void {
const owner = this.owner.get();
if (owner) {
owner._emit(Span.linkTapEvent);
}
view.clearFocus();
view.invalidate();
}
updateDrawState(tp: android.text.TextPaint): void {
// don't style as link
}
}

ClickableSpan = ClickableSpanImpl;
}

export class TextBase extends TextBaseCommon {
nativeViewProtected: android.widget.TextView;
nativeTextViewProtected: android.widget.TextView;
Expand All @@ -60,12 +96,15 @@ export class TextBase extends TextBaseCommon {
private _maxHeight: number;
private _minLines: number;
private _maxLines: number;
private _tappable: boolean = false;
private _defaultMovementMethod: android.text.method.MovementMethod;

public initNativeView(): void {
super.initNativeView();
initializeTextTransformation();
const nativeView = this.nativeTextViewProtected;
this._defaultTransformationMethod = nativeView.getTransformationMethod();
this._defaultMovementMethod = this.nativeView.getMovementMethod();
this._minHeight = nativeView.getMinHeight();
this._maxHeight = nativeView.getMaxHeight();
this._minLines = nativeView.getMinLines();
Expand Down Expand Up @@ -112,6 +151,8 @@ export class TextBase extends TextBaseCommon {
return;
}

this._setTappableState(false);

this._setNativeText(reset);
}

Expand All @@ -131,6 +172,7 @@ export class TextBase extends TextBaseCommon {

const spannableStringBuilder = createSpannableStringBuilder(value);
nativeView.setText(<any>spannableStringBuilder);
this._setTappableState(isStringTappable(value));

textProperty.nativeValueChange(this, (value === null || value === undefined) ? "" : value.toString());

Expand Down Expand Up @@ -315,6 +357,19 @@ export class TextBase extends TextBaseCommon {

this.nativeTextViewProtected.setText(<any>transformedText);
}

_setTappableState(tappable: boolean) {
if (this._tappable !== tappable) {
this._tappable = tappable;
if (this._tappable) {
this.nativeViewProtected.setMovementMethod(android.text.method.LinkMovementMethod.getInstance());
this.nativeViewProtected.setHighlightColor(null);
}
else {
this.nativeViewProtected.setMovementMethod(this._defaultMovementMethod);
}
}
}
}

function getCapitalizedString(str: string): string {
Expand Down Expand Up @@ -346,6 +401,20 @@ export function getTransformedText(text: string, textTransform: TextTransform):
}
}

function isStringTappable(formattedString: FormattedString) {
if (!formattedString) {
return false;
}
for (let i = 0, length = formattedString.spans.length; i < length; i++) {
const span = formattedString.spans.getItem(i);
if (span.tappable) {
return true;
}
}

return false;
}

function createSpannableStringBuilder(formattedString: FormattedString): android.text.SpannableStringBuilder {
if (!formattedString || !formattedString.parent) {
return null;
Expand Down Expand Up @@ -444,6 +513,12 @@ function setSpanModifiers(ssb: android.text.SpannableStringBuilder, span: Span,
}
}

const tappable = span.tappable;
if (tappable) {
initializeClickableSpan();
ssb.setSpan(new ClickableSpan(span), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}

// TODO: Implement letterSpacing for Span here.
// const letterSpacing = formattedString.parent.style.letterSpacing;
// if (letterSpacing > 0) {
Expand Down
108 changes: 108 additions & 0 deletions nativescript-core/ui/text-base/text-base.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,101 @@ export * from "./text-base-common";

const majorVersion = ios.MajorVersion;

class UILabelClickHandlerImpl extends NSObject {
private _owner: WeakRef<TextBase>;

public static initWithOwner(owner: WeakRef<TextBase>): UILabelClickHandlerImpl {
let handler = <UILabelClickHandlerImpl>UILabelClickHandlerImpl.new();
handler._owner = owner;

return handler;
}

public linkTap(tapGesture: UITapGestureRecognizer) {
let owner = this._owner.get();
if (owner) {
// https://stackoverflow.com/a/35789589
let label = <UILabel>owner.nativeTextViewProtected;
let layoutManager = NSLayoutManager.alloc().init();
let textContainer = NSTextContainer.alloc().initWithSize(CGSizeZero);
let textStorage = NSTextStorage.alloc().initWithAttributedString(owner.nativeTextViewProtected["attributedText"]);

layoutManager.addTextContainer(textContainer);
textStorage.addLayoutManager(layoutManager);

textContainer.lineFragmentPadding = 0;
textContainer.lineBreakMode = label.lineBreakMode;
textContainer.maximumNumberOfLines = label.numberOfLines;
let labelSize = label.bounds.size;
textContainer.size = labelSize;

let locationOfTouchInLabel = tapGesture.locationInView(label);
let textBoundingBox = layoutManager.usedRectForTextContainer(textContainer);

let textContainerOffset = CGPointMake((labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
(labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);

let locationOfTouchInTextContainer = CGPointMake(locationOfTouchInLabel.x - textContainerOffset.x,
locationOfTouchInLabel.y - textContainerOffset.y);

let indexOfCharacter = layoutManager.characterIndexForPointInTextContainerFractionOfDistanceBetweenInsertionPoints(
locationOfTouchInTextContainer, textContainer, null);

let span: Span = null;
// try to find the corresponding span using the spanRanges
for (let i = 0; i < owner._spanRanges.length; i++) {
let range = owner._spanRanges[i];
if ((range.location <= indexOfCharacter) && (range.location + range.length) > indexOfCharacter) {
if (owner.formattedText && owner.formattedText.spans.length > i) {
span = owner.formattedText.spans.getItem(i);
}
break;
}
}

if (span && span.tappable) {
// if the span is found and tappable emit the linkTap event
span._emit(Span.linkTapEvent);
}
}
}

public static ObjCExposedMethods = {
"linkTap": { returns: interop.types.void, params: [interop.types.id] }
};
}

export class TextBase extends TextBaseCommon {

public nativeViewProtected: UITextField | UITextView | UILabel | UIButton;
public nativeTextViewProtected: UITextField | UITextView | UILabel | UIButton;
private _tappable: boolean = false;
private _tapGestureRecognizer: UITapGestureRecognizer;
public _spanRanges: NSRange[];

public initNativeView(): void {
super.initNativeView();
this._setTappableState(false);
}

_setTappableState(tappable: boolean) {
if (this._tappable !== tappable) {
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;

this._tapGestureRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(tapHandler, "linkTap");
this.nativeViewProtected.userInteractionEnabled = true;
this.nativeViewProtected.addGestureRecognizer(this._tapGestureRecognizer);
}
else {
this.nativeViewProtected.userInteractionEnabled = false;
this.nativeViewProtected.removeGestureRecognizer(this._tapGestureRecognizer);
}
}
}

[textProperty.getDefault](): number | symbol {
return resetSymbol;
Expand All @@ -35,6 +126,7 @@ export class TextBase extends TextBaseCommon {

[formattedTextProperty.setNative](value: FormattedString) {
this._setNativeText();
this._setTappableState(isStringTappable(value));
textProperty.nativeValueChange(this, !value ? "" : value.toString());
this._requestLayoutOnTextChanged();
}
Expand Down Expand Up @@ -253,6 +345,7 @@ export class TextBase extends TextBaseCommon {

createNSMutableAttributedString(formattedString: FormattedString): NSMutableAttributedString {
let mas = NSMutableAttributedString.alloc().init();
this._spanRanges = [];
if (formattedString && formattedString.parent) {
for (let i = 0, spanStart = 0, length = formattedString.spans.length; i < length; i++) {
const span = formattedString.spans.getItem(i);
Expand All @@ -265,6 +358,7 @@ export class TextBase extends TextBaseCommon {

const nsAttributedString = this.createMutableStringForSpan(span, spanText);
mas.insertAttributedStringAtIndex(nsAttributedString, spanStart);
this._spanRanges.push({location: spanStart, length: spanText.length});
spanStart += spanText.length;
}
}
Expand Down Expand Up @@ -349,3 +443,17 @@ export function getTransformedText(text: string, textTransform: TextTransform):
function NSStringFromNSAttributedString(source: NSAttributedString | string): NSString {
return NSString.stringWithString(source instanceof NSAttributedString && source.string || <string>source);
}

function isStringTappable(formattedString: FormattedString) {
if (!formattedString) {
return false;
}
for (let i = 0, length = formattedString.spans.length; i < length; i++) {
const span = formattedString.spans.getItem(i);
if (span.tappable) {
return true;
}
}

return false;
}