From 8ab0e72bc95cc5ef46ab95e7129baba7a3a5c449 Mon Sep 17 00:00:00 2001 From: Vasil Trifonov Date: Fri, 20 Mar 2020 18:35:28 +0200 Subject: [PATCH] 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 --- api-reports/NativeScript.api.md | 8 +- e2e/ui-tests-app/app/button/main-page.ts | 1 + nativescript-core/ui/text-base/span.d.ts | 11 +- nativescript-core/ui/text-base/span.ts | 25 +++- .../ui/text-base/text-base.android.ts | 75 ++++++++++++ .../ui/text-base/text-base.ios.ts | 108 ++++++++++++++++++ 6 files changed, 224 insertions(+), 4 deletions(-) diff --git a/api-reports/NativeScript.api.md b/api-reports/NativeScript.api.md index 1d9a30d61..6d461503a 100644 --- a/api-reports/NativeScript.api.md +++ b/api-reports/NativeScript.api.md @@ -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 { @@ -2422,7 +2426,7 @@ export interface TapGestureEventData extends GestureEventData { getPointerCount(): number; getX(): number; - + getY(): number; } diff --git a/e2e/ui-tests-app/app/button/main-page.ts b/e2e/ui-tests-app/app/button/main-page.ts index ecb25dc25..5239daa13 100644 --- a/e2e/ui-tests-app/app/button/main-page.ts +++ b/e2e/ui-tests-app/app/button/main-page.ts @@ -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; } diff --git a/nativescript-core/ui/text-base/span.d.ts b/nativescript-core/ui/text-base/span.d.ts index 9c93d8ddc..f648dd198 100644 --- a/nativescript-core/ui/text-base/span.d.ts +++ b/nativescript-core/ui/text-base/span.d.ts @@ -50,6 +50,15 @@ 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 /** @@ -57,4 +66,4 @@ export class Span extends ViewBase { */ _setTextInternal(value: string): void; //@endprivate -} +} \ No newline at end of file diff --git a/nativescript-core/ui/text-base/span.ts b/nativescript-core/ui/text-base/span.ts index ce485cd15..c37adf340 100644 --- a/nativescript-core/ui/text-base/span.ts +++ b/nativescript-core/ui/text-base/span.ts @@ -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; @@ -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); + } + } } diff --git a/nativescript-core/ui/text-base/text-base.android.ts b/nativescript-core/ui/text-base/text-base.android.ts index d7cf1bfa7..200285c8a 100644 --- a/nativescript-core/ui/text-base/text-base.android.ts +++ b/nativescript-core/ui/text-base/text-base.android.ts @@ -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; + + 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; @@ -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(); @@ -112,6 +151,8 @@ export class TextBase extends TextBaseCommon { return; } + this._setTappableState(false); + this._setNativeText(reset); } @@ -131,6 +172,7 @@ export class TextBase extends TextBaseCommon { const spannableStringBuilder = createSpannableStringBuilder(value); nativeView.setText(spannableStringBuilder); + this._setTappableState(isStringTappable(value)); textProperty.nativeValueChange(this, (value === null || value === undefined) ? "" : value.toString()); @@ -315,6 +357,19 @@ export class TextBase extends TextBaseCommon { this.nativeTextViewProtected.setText(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 { @@ -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; @@ -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) { diff --git a/nativescript-core/ui/text-base/text-base.ios.ts b/nativescript-core/ui/text-base/text-base.ios.ts index 568f2ef03..0a1618afb 100644 --- a/nativescript-core/ui/text-base/text-base.ios.ts +++ b/nativescript-core/ui/text-base/text-base.ios.ts @@ -15,10 +15,101 @@ export * from "./text-base-common"; const majorVersion = ios.MajorVersion; +class UILabelClickHandlerImpl extends NSObject { + private _owner: WeakRef; + + public static initWithOwner(owner: WeakRef): UILabelClickHandlerImpl { + let handler = 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 = 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. + (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; @@ -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(); } @@ -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); @@ -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; } } @@ -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 || 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; +} \ No newline at end of file