Skip to content

Commit 8ab0e72

Browse files
vtrifonovedusperoni
andcommitted
feat: TappableSpan support (#8256)
* feat(android): clickable span Initial support for clickable span on Android * test: clickable-span test page * remove console.log * use _emit instead of notify * rename clickable to tappable in Span * updated NativeScript.api.md * chore: fixing tslint errors * chore: fixed witespacing * moved and improved test page * feat: tappable span iOS implementation Co-authored-by: Eduardo Speroni <edusperoni@gmail.com>
1 parent 92b5b02 commit 8ab0e72

6 files changed

Lines changed: 224 additions & 4 deletions

File tree

api-reports/NativeScript.api.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1936,14 +1936,18 @@ export class Span extends ViewBase {
19361936

19371937
public fontWeight: FontWeight;
19381938

1939+
public static linkTapEvent: string;
1940+
19391941
// (undocumented)
19401942
_setTextInternal(value: string): void;
19411943

1944+
public readonly tappable: boolean;
1945+
19421946
public text: string;
19431947

19441948
public textDecoration: TextDecoration;
19451949
//@endprivate
1946-
}
1950+
}
19471951

19481952
// @public
19491953
export class StackLayout extends LayoutBase {
@@ -2422,7 +2426,7 @@ export interface TapGestureEventData extends GestureEventData {
24222426
getPointerCount(): number;
24232427

24242428
getX(): number;
2425-
2429+
24262430
getY(): number;
24272431
}
24282432

e2e/ui-tests-app/app/button/main-page.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function loadExamples() {
1818
examples.set("issue-4287", "button/issue-4287-page");
1919
examples.set("issue-4385", "button/issue-4385-page");
2020
examples.set("highlight-4740", "button/highlight-4740/highlight-4740-page");
21+
examples.set("tappable-span", "button/tappable-span-page");
2122

2223
return examples;
2324
}

nativescript-core/ui/text-base/span.d.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,20 @@ export class Span extends ViewBase {
5050
* Gets or sets the text for the span.
5151
*/
5252
public text: string;
53+
/**
54+
* String value used when hooking to linkTap event.
55+
*/
56+
public static linkTapEvent: string;
57+
58+
/**
59+
* Gets if the span is tappable or not.
60+
*/
61+
public readonly tappable: boolean;
5362

5463
//@private
5564
/**
5665
* @private
5766
*/
5867
_setTextInternal(value: string): void;
5968
//@endprivate
60-
}
69+
}

nativescript-core/ui/text-base/span.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import { Span as SpanDefinition } from "./span";
33
import { ViewBase } from "../core/view";
44
import { FontStyle, FontWeight, } from "../styling/font";
5-
import { TextDecoration } from "../text-base";
5+
import { TextDecoration, EventData } from "../text-base";
66

77
export class Span extends ViewBase implements SpanDefinition {
8+
static linkTapEvent = "linkTap";
89
private _text: string;
10+
private _tappable: boolean = false;
911

1012
get fontFamily(): string {
1113
return this.style.fontFamily;
@@ -68,7 +70,28 @@ export class Span extends ViewBase implements SpanDefinition {
6870
}
6971
}
7072

73+
get tappable(): boolean {
74+
return this._tappable;
75+
}
76+
77+
addEventListener(arg: string, callback: (data: EventData) => void, thisArg?: any) {
78+
super.addEventListener(arg, callback, thisArg);
79+
this._setTappable(this.hasListeners(Span.linkTapEvent));
80+
}
81+
82+
removeEventListener(arg: string, callback?: any, thisArg?: any) {
83+
super.removeEventListener(arg, callback, thisArg);
84+
this._setTappable(this.hasListeners(Span.linkTapEvent));
85+
}
86+
7187
_setTextInternal(value: string): void {
7288
this._text = value;
7389
}
90+
91+
private _setTappable(value: boolean): void {
92+
if (this._tappable !== value) {
93+
this._tappable = value;
94+
this.notifyPropertyChange("tappable", value);
95+
}
96+
}
7497
}

nativescript-core/ui/text-base/text-base.android.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,42 @@ function initializeTextTransformation(): void {
5151
TextTransformation = TextTransformationImpl;
5252
}
5353

54+
interface ClickableSpan {
55+
new (owner: Span): android.text.style.ClickableSpan;
56+
}
57+
58+
let ClickableSpan: ClickableSpan;
59+
60+
function initializeClickableSpan(): void {
61+
if (ClickableSpan) {
62+
return;
63+
}
64+
65+
class ClickableSpanImpl extends android.text.style.ClickableSpan {
66+
owner: WeakRef<Span>;
67+
68+
constructor(owner: Span) {
69+
super();
70+
this.owner = new WeakRef(owner);
71+
72+
return global.__native(this);
73+
}
74+
onClick(view: android.view.View): void {
75+
const owner = this.owner.get();
76+
if (owner) {
77+
owner._emit(Span.linkTapEvent);
78+
}
79+
view.clearFocus();
80+
view.invalidate();
81+
}
82+
updateDrawState(tp: android.text.TextPaint): void {
83+
// don't style as link
84+
}
85+
}
86+
87+
ClickableSpan = ClickableSpanImpl;
88+
}
89+
5490
export class TextBase extends TextBaseCommon {
5591
nativeViewProtected: android.widget.TextView;
5692
nativeTextViewProtected: android.widget.TextView;
@@ -60,12 +96,15 @@ export class TextBase extends TextBaseCommon {
6096
private _maxHeight: number;
6197
private _minLines: number;
6298
private _maxLines: number;
99+
private _tappable: boolean = false;
100+
private _defaultMovementMethod: android.text.method.MovementMethod;
63101

64102
public initNativeView(): void {
65103
super.initNativeView();
66104
initializeTextTransformation();
67105
const nativeView = this.nativeTextViewProtected;
68106
this._defaultTransformationMethod = nativeView.getTransformationMethod();
107+
this._defaultMovementMethod = this.nativeView.getMovementMethod();
69108
this._minHeight = nativeView.getMinHeight();
70109
this._maxHeight = nativeView.getMaxHeight();
71110
this._minLines = nativeView.getMinLines();
@@ -112,6 +151,8 @@ export class TextBase extends TextBaseCommon {
112151
return;
113152
}
114153

154+
this._setTappableState(false);
155+
115156
this._setNativeText(reset);
116157
}
117158

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

132173
const spannableStringBuilder = createSpannableStringBuilder(value);
133174
nativeView.setText(<any>spannableStringBuilder);
175+
this._setTappableState(isStringTappable(value));
134176

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

@@ -315,6 +357,19 @@ export class TextBase extends TextBaseCommon {
315357

316358
this.nativeTextViewProtected.setText(<any>transformedText);
317359
}
360+
361+
_setTappableState(tappable: boolean) {
362+
if (this._tappable !== tappable) {
363+
this._tappable = tappable;
364+
if (this._tappable) {
365+
this.nativeViewProtected.setMovementMethod(android.text.method.LinkMovementMethod.getInstance());
366+
this.nativeViewProtected.setHighlightColor(null);
367+
}
368+
else {
369+
this.nativeViewProtected.setMovementMethod(this._defaultMovementMethod);
370+
}
371+
}
372+
}
318373
}
319374

320375
function getCapitalizedString(str: string): string {
@@ -346,6 +401,20 @@ export function getTransformedText(text: string, textTransform: TextTransform):
346401
}
347402
}
348403

404+
function isStringTappable(formattedString: FormattedString) {
405+
if (!formattedString) {
406+
return false;
407+
}
408+
for (let i = 0, length = formattedString.spans.length; i < length; i++) {
409+
const span = formattedString.spans.getItem(i);
410+
if (span.tappable) {
411+
return true;
412+
}
413+
}
414+
415+
return false;
416+
}
417+
349418
function createSpannableStringBuilder(formattedString: FormattedString): android.text.SpannableStringBuilder {
350419
if (!formattedString || !formattedString.parent) {
351420
return null;
@@ -444,6 +513,12 @@ function setSpanModifiers(ssb: android.text.SpannableStringBuilder, span: Span,
444513
}
445514
}
446515

516+
const tappable = span.tappable;
517+
if (tappable) {
518+
initializeClickableSpan();
519+
ssb.setSpan(new ClickableSpan(span), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
520+
}
521+
447522
// TODO: Implement letterSpacing for Span here.
448523
// const letterSpacing = formattedString.parent.style.letterSpacing;
449524
// if (letterSpacing > 0) {

nativescript-core/ui/text-base/text-base.ios.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,101 @@ export * from "./text-base-common";
1515

1616
const majorVersion = ios.MajorVersion;
1717

18+
class UILabelClickHandlerImpl extends NSObject {
19+
private _owner: WeakRef<TextBase>;
20+
21+
public static initWithOwner(owner: WeakRef<TextBase>): UILabelClickHandlerImpl {
22+
let handler = <UILabelClickHandlerImpl>UILabelClickHandlerImpl.new();
23+
handler._owner = owner;
24+
25+
return handler;
26+
}
27+
28+
public linkTap(tapGesture: UITapGestureRecognizer) {
29+
let owner = this._owner.get();
30+
if (owner) {
31+
// https://stackoverflow.com/a/35789589
32+
let label = <UILabel>owner.nativeTextViewProtected;
33+
let layoutManager = NSLayoutManager.alloc().init();
34+
let textContainer = NSTextContainer.alloc().initWithSize(CGSizeZero);
35+
let textStorage = NSTextStorage.alloc().initWithAttributedString(owner.nativeTextViewProtected["attributedText"]);
36+
37+
layoutManager.addTextContainer(textContainer);
38+
textStorage.addLayoutManager(layoutManager);
39+
40+
textContainer.lineFragmentPadding = 0;
41+
textContainer.lineBreakMode = label.lineBreakMode;
42+
textContainer.maximumNumberOfLines = label.numberOfLines;
43+
let labelSize = label.bounds.size;
44+
textContainer.size = labelSize;
45+
46+
let locationOfTouchInLabel = tapGesture.locationInView(label);
47+
let textBoundingBox = layoutManager.usedRectForTextContainer(textContainer);
48+
49+
let textContainerOffset = CGPointMake((labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
50+
(labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y);
51+
52+
let locationOfTouchInTextContainer = CGPointMake(locationOfTouchInLabel.x - textContainerOffset.x,
53+
locationOfTouchInLabel.y - textContainerOffset.y);
54+
55+
let indexOfCharacter = layoutManager.characterIndexForPointInTextContainerFractionOfDistanceBetweenInsertionPoints(
56+
locationOfTouchInTextContainer, textContainer, null);
57+
58+
let span: Span = null;
59+
// try to find the corresponding span using the spanRanges
60+
for (let i = 0; i < owner._spanRanges.length; i++) {
61+
let range = owner._spanRanges[i];
62+
if ((range.location <= indexOfCharacter) && (range.location + range.length) > indexOfCharacter) {
63+
if (owner.formattedText && owner.formattedText.spans.length > i) {
64+
span = owner.formattedText.spans.getItem(i);
65+
}
66+
break;
67+
}
68+
}
69+
70+
if (span && span.tappable) {
71+
// if the span is found and tappable emit the linkTap event
72+
span._emit(Span.linkTapEvent);
73+
}
74+
}
75+
}
76+
77+
public static ObjCExposedMethods = {
78+
"linkTap": { returns: interop.types.void, params: [interop.types.id] }
79+
};
80+
}
81+
1882
export class TextBase extends TextBaseCommon {
1983

2084
public nativeViewProtected: UITextField | UITextView | UILabel | UIButton;
2185
public nativeTextViewProtected: UITextField | UITextView | UILabel | UIButton;
86+
private _tappable: boolean = false;
87+
private _tapGestureRecognizer: UITapGestureRecognizer;
88+
public _spanRanges: NSRange[];
89+
90+
public initNativeView(): void {
91+
super.initNativeView();
92+
this._setTappableState(false);
93+
}
94+
95+
_setTappableState(tappable: boolean) {
96+
if (this._tappable !== tappable) {
97+
this._tappable = tappable;
98+
if (this._tappable) {
99+
const tapHandler = UILabelClickHandlerImpl.initWithOwner(new WeakRef(this));
100+
// associate handler with menuItem or it will get collected by JSC.
101+
(<any>this).handler = tapHandler;
102+
103+
this._tapGestureRecognizer = UITapGestureRecognizer.alloc().initWithTargetAction(tapHandler, "linkTap");
104+
this.nativeViewProtected.userInteractionEnabled = true;
105+
this.nativeViewProtected.addGestureRecognizer(this._tapGestureRecognizer);
106+
}
107+
else {
108+
this.nativeViewProtected.userInteractionEnabled = false;
109+
this.nativeViewProtected.removeGestureRecognizer(this._tapGestureRecognizer);
110+
}
111+
}
112+
}
22113

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

36127
[formattedTextProperty.setNative](value: FormattedString) {
37128
this._setNativeText();
129+
this._setTappableState(isStringTappable(value));
38130
textProperty.nativeValueChange(this, !value ? "" : value.toString());
39131
this._requestLayoutOnTextChanged();
40132
}
@@ -253,6 +345,7 @@ export class TextBase extends TextBaseCommon {
253345

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

266359
const nsAttributedString = this.createMutableStringForSpan(span, spanText);
267360
mas.insertAttributedStringAtIndex(nsAttributedString, spanStart);
361+
this._spanRanges.push({location: spanStart, length: spanText.length});
268362
spanStart += spanText.length;
269363
}
270364
}
@@ -349,3 +443,17 @@ export function getTransformedText(text: string, textTransform: TextTransform):
349443
function NSStringFromNSAttributedString(source: NSAttributedString | string): NSString {
350444
return NSString.stringWithString(source instanceof NSAttributedString && source.string || <string>source);
351445
}
446+
447+
function isStringTappable(formattedString: FormattedString) {
448+
if (!formattedString) {
449+
return false;
450+
}
451+
for (let i = 0, length = formattedString.spans.length; i < length; i++) {
452+
const span = formattedString.spans.getItem(i);
453+
if (span.tappable) {
454+
return true;
455+
}
456+
}
457+
458+
return false;
459+
}

0 commit comments

Comments
 (0)