From faa0181b9c95b0e0ca55e922f09bde804ac6537f Mon Sep 17 00:00:00 2001 From: "Bundyo (Kamen Bundev)" Date: Sun, 7 Jun 2020 00:03:05 +0300 Subject: [PATCH] feat(text-base): Add Span vertical-align support (#8257) --- .../ui/styling/style-properties.ts | 9 +- nativescript-core/ui/text-base/span.ts | 2 +- .../ui/text-base/text-base-common.ts | 16 ++- .../ui/text-base/text-base.android.ts | 109 +++++++++++----- .../ui/text-base/text-base.ios.ts | 118 ++++++++++-------- 5 files changed, 165 insertions(+), 89 deletions(-) diff --git a/nativescript-core/ui/styling/style-properties.ts b/nativescript-core/ui/styling/style-properties.ts index c2628fa2a..d2b5c3e33 100644 --- a/nativescript-core/ui/styling/style-properties.ts +++ b/nativescript-core/ui/styling/style-properties.ts @@ -358,13 +358,18 @@ export namespace HorizontalAlignment { export const horizontalAlignmentProperty = new CssProperty({ name: "horizontalAlignment", cssName: "horizontal-align", defaultValue: HorizontalAlignment.STRETCH, affectsLayout: isIOS, valueConverter: HorizontalAlignment.parse }); horizontalAlignmentProperty.register(Style); -export type VerticalAlignment = "top" | "middle" | "bottom" | "stretch"; +export type VerticalAlignment = "top" | "middle" | "bottom" | "stretch" | "text-top" | "text-bottom" | "super" | "sub" | "baseline"; export namespace VerticalAlignment { export const TOP: "top" = "top"; export const MIDDLE: "middle" = "middle"; export const BOTTOM: "bottom" = "bottom"; export const STRETCH: "stretch" = "stretch"; - export const isValid = makeValidator(TOP, MIDDLE, BOTTOM, STRETCH); + export const TEXTTOP: "text-top" = "text-top"; + export const TEXTBOTTOM: "text-bottom" = "text-bottom"; + export const SUPER: "super" = "super"; + export const SUB: "sub" = "sub"; + export const BASELINE: "baseline" = "baseline"; + export const isValid = makeValidator(TOP, MIDDLE, BOTTOM, STRETCH, TEXTTOP, TEXTBOTTOM, SUPER, SUB, BASELINE); export const parse = (value: string) => value.toLowerCase() === "center" ? MIDDLE : parseStrict(value); const parseStrict = makeParser(isValid); } diff --git a/nativescript-core/ui/text-base/span.ts b/nativescript-core/ui/text-base/span.ts index c37adf340..e2a648a34 100644 --- a/nativescript-core/ui/text-base/span.ts +++ b/nativescript-core/ui/text-base/span.ts @@ -65,7 +65,7 @@ export class Span extends ViewBase implements SpanDefinition { } set text(value: string) { if (this._text !== value) { - this._text = value; + this._text = value && value.replace("\\n", "\n").replace("\\t", "\t"); this.notifyPropertyChange("text", value); } } diff --git a/nativescript-core/ui/text-base/text-base-common.ts b/nativescript-core/ui/text-base/text-base-common.ts index a5da6f3c3..57b47ddfd 100644 --- a/nativescript-core/ui/text-base/text-base-common.ts +++ b/nativescript-core/ui/text-base/text-base-common.ts @@ -201,6 +201,18 @@ function onFormattedTextPropertyChanged(textBase: TextBaseCommon, oldValue: Form } } +export function getClosestPropertyValue(property: CssProperty, span: Span): T { + if (property.isSet(span.style)) { + return span.style[property.name]; + } else if (property.isSet(span.parent.style)) { + // parent is FormattedString + return span.parent.style[property.name]; + } else if (property.isSet(span.parent.parent.style)) { + // parent.parent is TextBase + return span.parent.parent.style[property.name]; + } +} + const textAlignmentConverter = makeParser(makeValidator("initial", "left", "center", "right")); export const textAlignmentProperty = new InheritedCssProperty({ name: "textAlignment", cssName: "text-align", defaultValue: "initial", valueConverter: textAlignmentConverter }); textAlignmentProperty.register(Style); @@ -217,10 +229,10 @@ const textDecorationConverter = makeParser(makeValidator({ name: "textDecoration", cssName: "text-decoration", defaultValue: "none", valueConverter: textDecorationConverter }); textDecorationProperty.register(Style); -export const letterSpacingProperty = new CssProperty({ name: "letterSpacing", cssName: "letter-spacing", defaultValue: 0, affectsLayout: isIOS, valueConverter: v => parseFloat(v) }); +export const letterSpacingProperty = new InheritedCssProperty({ name: "letterSpacing", cssName: "letter-spacing", defaultValue: 0, affectsLayout: isIOS, valueConverter: v => parseFloat(v) }); letterSpacingProperty.register(Style); -export const lineHeightProperty = new CssProperty({ name: "lineHeight", cssName: "line-height", affectsLayout: isIOS, valueConverter: v => parseFloat(v) }); +export const lineHeightProperty = new InheritedCssProperty({ name: "lineHeight", cssName: "line-height", affectsLayout: isIOS, valueConverter: v => parseFloat(v) }); lineHeightProperty.register(Style); export const resetSymbol = Symbol("textPropertyDefault"); diff --git a/nativescript-core/ui/text-base/text-base.android.ts b/nativescript-core/ui/text-base/text-base.android.ts index 356d32bad..808ac3f63 100644 --- a/nativescript-core/ui/text-base/text-base.android.ts +++ b/nativescript-core/ui/text-base/text-base.android.ts @@ -1,5 +1,5 @@ // Types -import { TextTransformation, TextDecoration, TextAlignment, TextTransform, WhiteSpace } from "./text-base-common"; +import { TextTransformation, TextDecoration, TextAlignment, TextTransform, WhiteSpace, getClosestPropertyValue } from "./text-base-common"; // Requires import { Font } from "../styling/font"; @@ -33,7 +33,7 @@ function initializeTextTransformation(): void { // NOTE: Do we need to transform the new text here? const formattedText = this.textBase.formattedText; if (formattedText) { - return createSpannableStringBuilder(formattedText); + return createSpannableStringBuilder(formattedText, (view).getTextSize()); } else { const text = this.textBase.text; @@ -170,7 +170,7 @@ export class TextBase extends TextBaseCommon { return; } - const spannableStringBuilder = createSpannableStringBuilder(value); + const spannableStringBuilder = createSpannableStringBuilder(value, this.style.fontSize); nativeView.setText(spannableStringBuilder); this._setTappableState(isStringTappable(value)); @@ -265,10 +265,11 @@ export class TextBase extends TextBaseCommon { } [lineHeightProperty.getDefault](): number { - return this.nativeTextViewProtected.getLineSpacingExtra() / layout.getDisplayDensity(); + return this.nativeTextViewProtected.getLineHeight() / layout.getDisplayDensity(); } [lineHeightProperty.setNative](value: number) { - this.nativeTextViewProtected.setLineSpacing(value * layout.getDisplayDensity(), 1); + const fontHeight = this.nativeTextViewProtected.getPaint().getFontMetricsInt(null); + this.nativeTextViewProtected.setLineSpacing(Math.max(value - fontHeight, 0) * layout.getDisplayDensity(), 1); } [fontInternalProperty.getDefault](): android.graphics.Typeface { @@ -348,7 +349,7 @@ export class TextBase extends TextBaseCommon { let transformedText: any; if (this.formattedText) { - transformedText = createSpannableStringBuilder(this.formattedText); + transformedText = createSpannableStringBuilder(this.formattedText, this.style.fontSize); } else { const text = this.text; const stringValue = (text === null || text === undefined) ? "" : text.toString(); @@ -415,7 +416,7 @@ function isStringTappable(formattedString: FormattedString) { return false; } -function createSpannableStringBuilder(formattedString: FormattedString): android.text.SpannableStringBuilder { +function createSpannableStringBuilder(formattedString: FormattedString, defaultFontSize: number): android.text.SpannableStringBuilder { if (!formattedString || !formattedString.parent) { return null; } @@ -433,7 +434,7 @@ function createSpannableStringBuilder(formattedString: FormattedString): android spanLength = spanText.length; if (spanLength > 0) { ssb.insert(spanStart, spanText); - setSpanModifiers(ssb, span, spanStart, spanStart + spanLength); + setSpanModifiers(ssb, span, spanStart, spanStart + spanLength, defaultFontSize); spanStart += spanLength; } } @@ -441,10 +442,67 @@ function createSpannableStringBuilder(formattedString: FormattedString): android return ssb; } -function setSpanModifiers(ssb: android.text.SpannableStringBuilder, span: Span, start: number, end: number): void { +class BaselineAdjustedSpan extends android.text.style.MetricAffectingSpan { + fontSize: number; + align: string | number = "baseline"; + + constructor(fontSize: number, align?: string | number) { + super(); + + this.align = align; + this.fontSize = fontSize; + } + + updateDrawState(paint: android.text.TextPaint) { + this.updateState(paint); + } + + updateMeasureState(paint: android.text.TextPaint) { + this.updateState(paint); + } + + updateState(paint: android.text.TextPaint) { + const metrics = paint.getFontMetrics(); + + if (!this.align || this.align === "baseline") { + return; + } + + if (this.align === "top") { + return paint.baselineShift = -this.fontSize - metrics.bottom - metrics.top; + } + + if (this.align === "bottom") { + return paint.baselineShift = metrics.bottom; + } + + if (this.align === "text-top") { + return paint.baselineShift = -this.fontSize - metrics.descent - metrics.ascent; + } + + if (this.align === "text-bottom") { + return paint.baselineShift = metrics.bottom - metrics.descent; + } + + if (this.align === "middle") { + return paint.baselineShift = (metrics.descent - metrics.ascent) / 2 - metrics.descent; + } + + if (this.align === "super") { + return paint.baselineShift = -this.fontSize * .4; + } + + if (this.align === "sub") { + return paint.baselineShift = (metrics.descent - metrics.ascent) * .4; + } + } +} + +function setSpanModifiers(ssb: android.text.SpannableStringBuilder, span: Span, start: number, end: number, defaultFontSize: number): void { const spanStyle = span.style; const bold = isBold(spanStyle.fontWeight); const italic = spanStyle.fontStyle === "italic"; + const align = spanStyle.verticalAlignment; if (bold && italic) { ssb.setSpan(new android.text.style.StyleSpan(android.graphics.Typeface.BOLD_ITALIC), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -474,45 +532,30 @@ 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); } - let backgroundColor: Color; - if (backgroundColorProperty.isSet(spanStyle)) { - backgroundColor = spanStyle.backgroundColor; - } else if (backgroundColorProperty.isSet(span.parent.style)) { - // parent is FormattedString - backgroundColor = span.parent.style.backgroundColor; - } else if (backgroundColorProperty.isSet(span.parent.parent.style)) { - // parent.parent is TextBase - backgroundColor = span.parent.parent.style.backgroundColor; - } + const backgroundColor: Color = getClosestPropertyValue(backgroundColorProperty, span); if (backgroundColor) { ssb.setSpan(new android.text.style.BackgroundColorSpan(backgroundColor.android), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - let valueSource: typeof spanStyle; - if (textDecorationProperty.isSet(spanStyle)) { - valueSource = spanStyle; - } else if (textDecorationProperty.isSet(span.parent.style)) { - // span.parent is FormattedString - valueSource = span.parent.style; - } else if (textDecorationProperty.isSet(span.parent.parent.style)) { - // span.parent.parent is TextBase - valueSource = span.parent.parent.style; - } + const textDecoration: TextDecoration = getClosestPropertyValue(textDecorationProperty, span); - if (valueSource) { - const textDecorations = valueSource.textDecoration; - const underline = textDecorations.indexOf("underline") !== -1; + if (textDecoration) { + const underline = textDecoration.indexOf("underline") !== -1; if (underline) { ssb.setSpan(new android.text.style.UnderlineSpan(), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } - const strikethrough = textDecorations.indexOf("line-through") !== -1; + const strikethrough = textDecoration.indexOf("line-through") !== -1; if (strikethrough) { ssb.setSpan(new android.text.style.StrikethroughSpan(), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } + if (align) { + ssb.setSpan(new BaselineAdjustedSpan(defaultFontSize * layout.getDisplayDensity(), align), start, end, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + const tappable = span.tappable; if (tappable) { initializeClickableSpan(); diff --git a/nativescript-core/ui/text-base/text-base.ios.ts b/nativescript-core/ui/text-base/text-base.ios.ts index 963451adf..fa36b9f8a 100644 --- a/nativescript-core/ui/text-base/text-base.ios.ts +++ b/nativescript-core/ui/text-base/text-base.ios.ts @@ -1,15 +1,16 @@ // Types -import { TextDecoration, TextAlignment, TextTransform } from "./text-base-common"; +import { TextDecoration, TextAlignment, TextTransform, layout, getClosestPropertyValue } from "./text-base-common"; // Requires import { Font } from "../styling/font"; import { TextBaseCommon, textProperty, formattedTextProperty, textAlignmentProperty, textDecorationProperty, textTransformProperty, letterSpacingProperty, colorProperty, fontInternalProperty, lineHeightProperty, - FormattedString, Span, Color, isBold, resetSymbol + FormattedString, Span, Color, resetSymbol } from "./text-base-common"; import { isString } from "../../utils/types"; import { ios } from "../../utils/utils"; +import { Property } from "../core/properties/properties"; export * from "./text-base-common"; @@ -229,7 +230,7 @@ export class TextBase extends TextBaseCommon { if (this.style.lineHeight) { const paragraphStyle = NSMutableParagraphStyle.alloc().init(); - paragraphStyle.lineSpacing = this.lineHeight; + paragraphStyle.minimumLineHeight = this.lineHeight; // make sure a possible previously set text alignment setting is not lost when line height is specified if (this.nativeTextViewProtected instanceof UIButton) { paragraphStyle.alignment = (this.nativeTextViewProtected).titleLabel.textAlignment; @@ -280,12 +281,15 @@ export class TextBase extends TextBaseCommon { throw new Error(`Invalid text decoration value: ${style.textDecoration}. Valid values are: 'none', 'underline', 'line-through', 'underline line-through'.`); } - if (style.letterSpacing !== 0) { + if (style.letterSpacing !== 0 && this.nativeTextViewProtected.font) { const kern = style.letterSpacing * this.nativeTextViewProtected.font.pointSize dict.set(NSKernAttributeName, kern); - if (this.nativeTextViewProtected instanceof UITextField) { - this.nativeTextViewProtected.defaultTextAttributes.setObjectForKey(kern, NSKernAttributeName); - } + } + + if (style.color) { + dict.set(NSForegroundColorAttributeName, style.color.ios); + } else if (majorVersion >= 13 && UIColor.labelColor) { + dict.set(NSForegroundColorAttributeName, UIColor.labelColor); } const isTextView = this.nativeTextViewProtected instanceof UITextView; @@ -310,19 +314,9 @@ export class TextBase extends TextBaseCommon { dict.set(NSParagraphStyleAttributeName, paragraphStyle); } + const source = getTransformedText(this.text ? this.text.toString() : "", this.textTransform); if (dict.size > 0 || isTextView) { - if (style.color) { - dict.set(NSForegroundColorAttributeName, style.color.ios); - } else if (majorVersion >= 13 && UIColor.labelColor) { - dict.set(NSForegroundColorAttributeName, UIColor.labelColor); - } - } - - const text = this.text; - const string = (text === undefined || text === null) ? "" : text.toString(); - const source = getTransformedText(string, this.textTransform); - if (dict.size > 0 || isTextView) { - if (isTextView) { + if (isTextView && this.nativeTextViewProtected.font) { // UITextView's font seems to change inside. dict.set(NSFontAttributeName, this.nativeTextViewProtected.font); } @@ -370,59 +364,81 @@ export class TextBase extends TextBaseCommon { return mas; } - createMutableStringForSpan(span: Span, text: string): NSMutableAttributedString { - const viewFont = this.nativeTextViewProtected.font; - let attrDict = <{ key: string, value: any }>{}; - const style = span.style; - const bold = isBold(style.fontWeight); - const italic = style.fontStyle === "italic"; - - let fontFamily = span.fontFamily; - let fontSize = span.fontSize; - - if (bold || italic || fontFamily || fontSize) { - let font = new Font(style.fontFamily, style.fontSize, style.fontStyle, style.fontWeight); - let iosFont = font.getUIFont(viewFont); - attrDict[NSFontAttributeName] = iosFont; + getBaselineOffset(font: UIFont, align?: string | number): number { + if (!align || align === "baseline") { + return 0; } - const color = span.color; - if (color) { - attrDict[NSForegroundColorAttributeName] = color.ios; + if (align === "top") { + return -this.fontSize - font.descender - font.ascender - font.leading / 2; + } + + if (align === "bottom") { + return font.descender + font.leading / 2; + } + + if (align === "text-top") { + return -this.fontSize - font.descender - font.ascender; + } + + if (align === "text-bottom") { + return font.descender; + } + + if (align === "middle") { + return (font.descender - font.ascender) / 2 - font.descender; + } + + if (align === "super") { + return -this.fontSize * .4; + } + + if (align === "sub") { + return (font.descender - font.ascender) * .4; + } + } + + createMutableStringForSpan(span: Span, text: string): NSMutableAttributedString { + const viewFont = this.nativeTextViewProtected.font; + const attrDict = <{ key: string, value: any }>{}; + const style = span.style; + const align = style.verticalAlignment; + + const font = new Font(style.fontFamily, style.fontSize, style.fontStyle, style.fontWeight); + const iosFont = font.getUIFont(viewFont); + + attrDict[NSFontAttributeName] = iosFont; + + if (span.color) { + attrDict[NSForegroundColorAttributeName] = span.color.ios; } // We don't use isSet function here because defaultValue for backgroundColor is null. const backgroundColor = (style.backgroundColor || (span.parent).backgroundColor - || ((span.parent).parent).backgroundColor); + || (span.parent.parent).backgroundColor); if (backgroundColor) { attrDict[NSBackgroundColorAttributeName] = backgroundColor.ios; } - let valueSource: typeof style; - if (textDecorationProperty.isSet(style)) { - valueSource = style; - } else if (textDecorationProperty.isSet(span.parent.style)) { - // span.parent is FormattedString - valueSource = span.parent.style; - } else if (textDecorationProperty.isSet(span.parent.parent.style)) { - // span.parent.parent is TextBase - valueSource = span.parent.parent.style; - } + const textDecoration: TextDecoration = getClosestPropertyValue(textDecorationProperty, span); - if (valueSource) { - const textDecorations = valueSource.textDecoration; - const underline = textDecorations.indexOf("underline") !== -1; + if (textDecoration) { + const underline = textDecoration.indexOf("underline") !== -1; if (underline) { attrDict[NSUnderlineStyleAttributeName] = underline; } - const strikethrough = textDecorations.indexOf("line-through") !== -1; + const strikethrough = textDecoration.indexOf("line-through") !== -1; if (strikethrough) { attrDict[NSStrikethroughStyleAttributeName] = strikethrough; } } + if (align) { + attrDict[NSBaselineOffsetAttributeName] = this.getBaselineOffset(iosFont, align); + } + return NSMutableAttributedString.alloc().initWithStringAttributes(text, attrDict); } }