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>
This commit is contained in:
Vasil Trifonov
2020-03-20 18:35:28 +02:00
parent 92b5b02bf5
commit 8ab0e72bc9
6 changed files with 224 additions and 4 deletions

View File

@@ -1936,14 +1936,18 @@ export class Span extends ViewBase {
public fontWeight: FontWeight; public fontWeight: FontWeight;
public static linkTapEvent: string;
// (undocumented) // (undocumented)
_setTextInternal(value: string): void; _setTextInternal(value: string): void;
public readonly tappable: boolean;
public text: string; public text: string;
public textDecoration: TextDecoration; public textDecoration: TextDecoration;
//@endprivate //@endprivate
} }
// @public // @public
export class StackLayout extends LayoutBase { export class StackLayout extends LayoutBase {
@@ -2422,7 +2426,7 @@ export interface TapGestureEventData extends GestureEventData {
getPointerCount(): number; getPointerCount(): number;
getX(): number; getX(): number;
getY(): number; getY(): number;
} }

View File

@@ -18,6 +18,7 @@ export function loadExamples() {
examples.set("issue-4287", "button/issue-4287-page"); examples.set("issue-4287", "button/issue-4287-page");
examples.set("issue-4385", "button/issue-4385-page"); examples.set("issue-4385", "button/issue-4385-page");
examples.set("highlight-4740", "button/highlight-4740/highlight-4740-page"); examples.set("highlight-4740", "button/highlight-4740/highlight-4740-page");
examples.set("tappable-span", "button/tappable-span-page");
return examples; return examples;
} }

View File

@@ -50,6 +50,15 @@ export class Span extends ViewBase {
* Gets or sets the text for the span. * Gets or sets the text for the span.
*/ */
public text: string; 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
/** /**
@@ -57,4 +66,4 @@ export class Span extends ViewBase {
*/ */
_setTextInternal(value: string): void; _setTextInternal(value: string): void;
//@endprivate //@endprivate
} }

View File

@@ -2,10 +2,12 @@
import { Span as SpanDefinition } from "./span"; import { Span as SpanDefinition } from "./span";
import { ViewBase } from "../core/view"; import { ViewBase } from "../core/view";
import { FontStyle, FontWeight, } from "../styling/font"; import { FontStyle, FontWeight, } from "../styling/font";
import { TextDecoration } from "../text-base"; import { TextDecoration, EventData } from "../text-base";
export class Span extends ViewBase implements SpanDefinition { export class Span extends ViewBase implements SpanDefinition {
static linkTapEvent = "linkTap";
private _text: string; private _text: string;
private _tappable: boolean = false;
get fontFamily(): string { get fontFamily(): string {
return this.style.fontFamily; return this.style.fontFamily;
@@ -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 { _setTextInternal(value: string): void {
this._text = value; this._text = value;
} }
private _setTappable(value: boolean): void {
if (this._tappable !== value) {
this._tappable = value;
this.notifyPropertyChange("tappable", value);
}
}
} }

View File

@@ -51,6 +51,42 @@ function initializeTextTransformation(): void {
TextTransformation = TextTransformationImpl; 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 { export class TextBase extends TextBaseCommon {
nativeViewProtected: android.widget.TextView; nativeViewProtected: android.widget.TextView;
nativeTextViewProtected: android.widget.TextView; nativeTextViewProtected: android.widget.TextView;
@@ -60,12 +96,15 @@ export class TextBase extends TextBaseCommon {
private _maxHeight: number; private _maxHeight: number;
private _minLines: number; private _minLines: number;
private _maxLines: number; private _maxLines: number;
private _tappable: boolean = false;
private _defaultMovementMethod: android.text.method.MovementMethod;
public initNativeView(): void { public initNativeView(): void {
super.initNativeView(); super.initNativeView();
initializeTextTransformation(); initializeTextTransformation();
const nativeView = this.nativeTextViewProtected; const nativeView = this.nativeTextViewProtected;
this._defaultTransformationMethod = nativeView.getTransformationMethod(); this._defaultTransformationMethod = nativeView.getTransformationMethod();
this._defaultMovementMethod = this.nativeView.getMovementMethod();
this._minHeight = nativeView.getMinHeight(); this._minHeight = nativeView.getMinHeight();
this._maxHeight = nativeView.getMaxHeight(); this._maxHeight = nativeView.getMaxHeight();
this._minLines = nativeView.getMinLines(); this._minLines = nativeView.getMinLines();
@@ -112,6 +151,8 @@ export class TextBase extends TextBaseCommon {
return; return;
} }
this._setTappableState(false);
this._setNativeText(reset); this._setNativeText(reset);
} }
@@ -131,6 +172,7 @@ export class TextBase extends TextBaseCommon {
const spannableStringBuilder = createSpannableStringBuilder(value); const spannableStringBuilder = createSpannableStringBuilder(value);
nativeView.setText(<any>spannableStringBuilder); nativeView.setText(<any>spannableStringBuilder);
this._setTappableState(isStringTappable(value));
textProperty.nativeValueChange(this, (value === null || value === undefined) ? "" : value.toString()); textProperty.nativeValueChange(this, (value === null || value === undefined) ? "" : value.toString());
@@ -315,6 +357,19 @@ export class TextBase extends TextBaseCommon {
this.nativeTextViewProtected.setText(<any>transformedText); 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 { function getCapitalizedString(str: string): string {
@@ -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 { function createSpannableStringBuilder(formattedString: FormattedString): android.text.SpannableStringBuilder {
if (!formattedString || !formattedString.parent) { if (!formattedString || !formattedString.parent) {
return null; return null;
@@ -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. // TODO: Implement letterSpacing for Span here.
// const letterSpacing = formattedString.parent.style.letterSpacing; // const letterSpacing = formattedString.parent.style.letterSpacing;
// if (letterSpacing > 0) { // if (letterSpacing > 0) {

View File

@@ -15,10 +15,101 @@ export * from "./text-base-common";
const majorVersion = ios.MajorVersion; 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 { export class TextBase extends TextBaseCommon {
public nativeViewProtected: UITextField | UITextView | UILabel | UIButton; public nativeViewProtected: UITextField | UITextView | UILabel | UIButton;
public nativeTextViewProtected: 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 { [textProperty.getDefault](): number | symbol {
return resetSymbol; return resetSymbol;
@@ -35,6 +126,7 @@ export class TextBase extends TextBaseCommon {
[formattedTextProperty.setNative](value: FormattedString) { [formattedTextProperty.setNative](value: FormattedString) {
this._setNativeText(); this._setNativeText();
this._setTappableState(isStringTappable(value));
textProperty.nativeValueChange(this, !value ? "" : value.toString()); textProperty.nativeValueChange(this, !value ? "" : value.toString());
this._requestLayoutOnTextChanged(); this._requestLayoutOnTextChanged();
} }
@@ -253,6 +345,7 @@ export class TextBase extends TextBaseCommon {
createNSMutableAttributedString(formattedString: FormattedString): NSMutableAttributedString { createNSMutableAttributedString(formattedString: FormattedString): NSMutableAttributedString {
let mas = NSMutableAttributedString.alloc().init(); let mas = NSMutableAttributedString.alloc().init();
this._spanRanges = [];
if (formattedString && formattedString.parent) { if (formattedString && formattedString.parent) {
for (let i = 0, spanStart = 0, length = formattedString.spans.length; i < length; i++) { for (let i = 0, spanStart = 0, length = formattedString.spans.length; i < length; i++) {
const span = formattedString.spans.getItem(i); const span = formattedString.spans.getItem(i);
@@ -265,6 +358,7 @@ export class TextBase extends TextBaseCommon {
const nsAttributedString = this.createMutableStringForSpan(span, spanText); const nsAttributedString = this.createMutableStringForSpan(span, spanText);
mas.insertAttributedStringAtIndex(nsAttributedString, spanStart); mas.insertAttributedStringAtIndex(nsAttributedString, spanStart);
this._spanRanges.push({location: spanStart, length: spanText.length});
spanStart += spanText.length; spanStart += spanText.length;
} }
} }
@@ -349,3 +443,17 @@ export function getTransformedText(text: string, textTransform: TextTransform):
function NSStringFromNSAttributedString(source: NSAttributedString | string): NSString { function NSStringFromNSAttributedString(source: NSAttributedString | string): NSString {
return NSString.stringWithString(source instanceof NSAttributedString && source.string || <string>source); 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;
}