From c379fc345bb1bf0f5660fb9b0b12fdef04c8fa7f Mon Sep 17 00:00:00 2001 From: iPel Date: Mon, 27 Nov 2023 14:09:56 +0800 Subject: [PATCH] feat(android): support text decoration features * support `textDecorationColor` and `textDecorationStyle` props * improve `verticalAlign` with line-breaking --- .../tencent/mtt/hippy/dom/node/NodeProps.java | 2 + .../component/drawable/TextDrawable.java | 28 +- .../component/text/TextDecorationSpan.java | 149 +++++++ .../component/text/TextImageSpan.java | 10 +- .../component/text/TextLineMetricsHelper.java | 380 ++++++++++++++++++ .../component/text/TextShadowSpan.java | 11 +- .../component/text/TextVerticalAlignSpan.java | 30 +- .../renderer/node/ImageVirtualNode.java | 37 +- .../renderer/node/TextVirtualNode.java | 141 ++++--- .../tencent/renderer/node/VirtualNode.java | 47 ++- 10 files changed, 728 insertions(+), 107 deletions(-) create mode 100644 renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextDecorationSpan.java create mode 100644 renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextLineMetricsHelper.java diff --git a/renderer/native/android/src/main/java/com/tencent/mtt/hippy/dom/node/NodeProps.java b/renderer/native/android/src/main/java/com/tencent/mtt/hippy/dom/node/NodeProps.java index a09c38617ff..a813ccf7cbb 100644 --- a/renderer/native/android/src/main/java/com/tencent/mtt/hippy/dom/node/NodeProps.java +++ b/renderer/native/android/src/main/java/com/tencent/mtt/hippy/dom/node/NodeProps.java @@ -107,6 +107,8 @@ public class NodeProps { public static final String TEXT_ALIGN = "textAlign"; public static final String TEXT_ALIGN_VERTICAL = "textAlignVertical"; public static final String TEXT_DECORATION_LINE = "textDecorationLine"; + public static final String TEXT_DECORATION_COLOR = "textDecorationColor"; + public static final String TEXT_DECORATION_STYLE = "textDecorationStyle"; public static final String TEXT_SHADOW_OFFSET = "textShadowOffset"; public static final String TEXT_SHADOW_RADIUS = "textShadowRadius"; public static final String TEXT_SHADOW_COLOR = "textShadowColor"; diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/TextDrawable.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/TextDrawable.java index b9f33f76fc7..f5ec72d9826 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/TextDrawable.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/TextDrawable.java @@ -42,9 +42,11 @@ import android.graphics.drawable.Drawable; import android.text.Layout; +import android.text.Spanned; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.tencent.renderer.component.text.TextLineMetricsHelper; import com.tencent.renderer.component.text.TextRenderSupplier; public class TextDrawable extends Drawable { @@ -60,8 +62,10 @@ public class TextDrawable extends Drawable { private Layout mLayout; @Nullable private BackgroundHolder mBackgroundHolder; + private TextLineMetricsHelper mHelper; public void setTextLayout(@NonNull Object obj) { + Layout oldLayout = mLayout; if (obj instanceof TextRenderSupplier) { mLayout = ((TextRenderSupplier) obj).layout; mLeftPadding = ((TextRenderSupplier) obj).leftPadding; @@ -71,6 +75,22 @@ public void setTextLayout(@NonNull Object obj) { } else if (obj instanceof Layout) { mLayout = (Layout) obj; } + if (mLayout != oldLayout) { + mHelper = getTextLineMetricsHelper(mLayout); + } + } + + private TextLineMetricsHelper getTextLineMetricsHelper(Layout layout) { + if (layout != null) { + CharSequence text = layout.getText(); + if (text instanceof Spanned) { + TextLineMetricsHelper[] spans = ((Spanned) text).getSpans(0, 0, TextLineMetricsHelper.class); + if (spans != null && spans.length > 0) { + return spans[0]; + } + } + } + return null; } public void setBackgroundHolder(@Nullable BackgroundHolder holder) { @@ -119,7 +139,13 @@ public void draw(@NonNull Canvas canvas) { if (paint != null) { paint.setFakeBoldText(mFakeBoldText); } - mLayout.draw(canvas); + if (mHelper != null) { + mHelper.initialize(); + mLayout.draw(canvas); + mHelper.drawTextDecoration(canvas, mLayout); + } else { + mLayout.draw(canvas); + } canvas.restore(); } diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextDecorationSpan.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextDecorationSpan.java new file mode 100644 index 00000000000..3be78f68247 --- /dev/null +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextDecorationSpan.java @@ -0,0 +1,149 @@ +/* Tencent is pleased to support the open source community by making Hippy available. + * Copyright (C) 2018 THL A29 Limited, a Tencent company. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.renderer.component.text; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.text.TextPaint; +import android.text.style.CharacterStyle; +import android.text.style.ReplacementSpan; +import android.text.style.UpdateAppearance; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TextDecorationSpan extends CharacterStyle implements UpdateAppearance, TextLineMetricsHelper.LineMetrics { + + public static final int STYLE_SOLID = 0; + public static final int STYLE_DOUBLE = 1; + public static final int STYLE_DOTTED = 2; + public static final int STYLE_DASHED = 3; + // according to kStdUnderline_Thickness defined in aosp's Paint.h + private static final float THICKNESS = (1.0f / 18.0f); + + private final boolean underline; + private final boolean lineThrough; + private final int color; + private final int style; + private final boolean needSpecialDraw; + + private TextLineMetricsHelper helper; + + public TextDecorationSpan(boolean underline, boolean lineThrough, int color, int style) { + assert underline || lineThrough; + this.underline = underline; + this.lineThrough = lineThrough; + this.color = color; + this.style = style; + needSpecialDraw = style != STYLE_SOLID || (lineThrough && color != Color.TRANSPARENT); + } + + public boolean needSpecialDraw() { + return needSpecialDraw; + } + + @Override + public void updateDrawState(TextPaint tp) { + if (!underline && !lineThrough) { + // should never happened + return; + } + if (needSpecialDraw && helper != null) { + int color = this.color == Color.TRANSPARENT ? tp.getColor() : this.color; + helper.markTextDecoration(underline, lineThrough, color, style, tp.getTextSize()); + return; + } + if (underline) { + if (color == Color.TRANSPARENT || !trySetUnderlineColor(tp, color, tp.getTextSize() * THICKNESS)) { + tp.setUnderlineText(true); + } + } + if (lineThrough) { + tp.setStrikeThruText(true); + } + } + + private static boolean trySetUnderlineColor(TextPaint tp, int color, float thickness) { + try { + // Since these were @hide fields made public, we can link directly against it with + // a try/catch for its absence instead of doing the same through reflection. + // noinspection NewApi + tp.underlineColor = color; + // noinspection NewApi + tp.underlineThickness = thickness; + return true; + } catch (NoSuchFieldError e) { + return false; + } + } + + @Override + public void setLineMetrics(TextLineMetricsHelper helper) { + if (needSpecialDraw) { + this.helper = helper; + } + } + + public static final class StartMark extends ReplacementSpan implements TextLineMetricsHelper.LineMetrics { + + private TextLineMetricsHelper helper; + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, + @Nullable Paint.FontMetricsInt fm) { + return 0; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, + int bottom, @NonNull Paint paint) { + if (helper != null) { + helper.markTextDecorationStart(this, start, x, y); + } + } + + @Override + public void setLineMetrics(TextLineMetricsHelper helper) { + this.helper = helper; + } + } + + public static final class EndMark extends ReplacementSpan implements TextLineMetricsHelper.LineMetrics { + + private TextLineMetricsHelper helper; + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, + @Nullable Paint.FontMetricsInt fm) { + return 0; + } + + @Override + public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, + int bottom, @NonNull Paint paint) { + if (helper != null) { + helper.markTextDecorationEnd(x); + } + } + + @Override + public void setLineMetrics(TextLineMetricsHelper helper) { + this.helper = helper; + } + } +} diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextImageSpan.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextImageSpan.java index 8d27a76b768..dd217528b1b 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextImageSpan.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextImageSpan.java @@ -51,7 +51,7 @@ import com.tencent.renderer.component.image.ImageLoaderAdapter; import com.tencent.renderer.component.image.ImageRequestListener; import com.tencent.renderer.node.ImageVirtualNode; -import com.tencent.renderer.node.TextVirtualNode; +import com.tencent.renderer.node.VirtualNode; import com.tencent.renderer.utils.EventUtils.EventType; import java.lang.ref.WeakReference; import java.lang.reflect.Field; @@ -184,16 +184,16 @@ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, int transY; assert mVerticalAlign != null; switch (mVerticalAlign) { - case TextVirtualNode.V_ALIGN_TOP: + case VirtualNode.V_ALIGN_TOP: transY = top + mMarginTop; break; - case TextVirtualNode.V_ALIGN_MIDDLE: + case VirtualNode.V_ALIGN_MIDDLE: transY = top + (bottom - top) / 2 - mMeasuredHeight / 2; break; - case TextVirtualNode.V_ALIGN_BOTTOM: + case VirtualNode.V_ALIGN_BOTTOM: transY = bottom - mMeasuredHeight - mMarginBottom; break; - case TextVirtualNode.V_ALIGN_BASELINE: + case VirtualNode.V_ALIGN_BASELINE: default: transY = y - mMeasuredHeight - mMarginBottom; break; diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextLineMetricsHelper.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextLineMetricsHelper.java new file mode 100644 index 00000000000..af791b71cdf --- /dev/null +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextLineMetricsHelper.java @@ -0,0 +1,380 @@ +/* Tencent is pleased to support the open source community by making Hippy available. + * Copyright (C) 2018 THL A29 Limited, a Tencent company. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tencent.renderer.component.text; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.DashPathEffect; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.PathDashPathEffect; +import android.graphics.PathEffect; +import android.os.Build; +import android.text.Layout; +import android.text.style.LeadingMarginSpan; +import android.util.SparseArray; +import androidx.annotation.NonNull; +import com.tencent.mtt.hippy.utils.PixelUtil; +import java.util.ArrayList; + +public class TextLineMetricsHelper implements LeadingMarginSpan { + + private boolean running; + private int lineTop; + private int lineBottom; + private final ArrayList textDecorationRecords = new ArrayList<>(); + private int textDecorationRecordCount = 0; + private TextDecorationRecord startedRecord; + private Paint textDecorationPaint; + private SparseArray dashedEffect; + private SparseArray dottedEffect; + private Path reusablePath; + + @Override + public int getLeadingMargin(boolean first) { + return 0; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + if (!running) { + return; + } + lineTop = top - baseline; + lineBottom = bottom - baseline; + if (startedRecord != null) { + // handle line break between text decoration + if (startedRecord.underline || startedRecord.lineThrough) { + // the started record is not empty, we should start a new record for the new line + TextDecorationRecord lastRecord = startedRecord; + startedRecord = obtainTextDecorationRecord(); + startedRecord.mark = lastRecord.mark; + startedRecord.lineOfSpan = lastRecord.lineOfSpan + 1; + startedRecord.indexOfChars = lastRecord.indexOfChars; + } else { + // otherwise reuse it + ++startedRecord.lineOfSpan; + } + startedRecord.left = -1; + startedRecord.baseline = baseline; + } + } + + private TextDecorationRecord obtainTextDecorationRecord() { + TextDecorationRecord record; + if (textDecorationRecords.size() > textDecorationRecordCount) { + record = textDecorationRecords.get(textDecorationRecordCount); + } else { + record = new TextDecorationRecord(); + textDecorationRecords.add(record); + } + ++textDecorationRecordCount; + return record; + } + + public int getLineTop() { + return lineTop; + } + + public int getLineBottom() { + return lineBottom; + } + + public void initialize() { + reset(); + running = true; + } + + private void reset() { + // since this is running within draw, we reset the count and reuse the items + // rather than {@link ArrayList#clear()} to avoid allocating objects frequently + for (int i = 0; i < textDecorationRecordCount; ++i) { + textDecorationRecords.get(i).reset(); + } + lineTop = 0; + lineBottom = 0; + textDecorationRecordCount = 0; + startedRecord = null; + running = false; + } + + public void markTextDecorationStart(TextDecorationSpan.StartMark span, int index, float x, float baseline) { + if (!running) { + return; + } + startedRecord = obtainTextDecorationRecord(); + startedRecord.mark = span; + startedRecord.indexOfChars = index; + startedRecord.left = x; + startedRecord.baseline = baseline; + } + + public void markTextDecoration(boolean underline, boolean lineThrough, int color, int style, float textSize) { + if (startedRecord != null) { + startedRecord.underline = underline; + startedRecord.lineThrough = lineThrough; + startedRecord.color = color; + startedRecord.style = style; + startedRecord.textSize = textSize; + } + } + + public void markTextDecorationEnd(float x) { + if (startedRecord != null) { + if (startedRecord.underline || startedRecord.lineThrough) { + // the started record is not empty, mark end + startedRecord.right = x; + } else { + // otherwise recycle it + startedRecord.reset(); + --textDecorationRecordCount; + } + startedRecord = null; + } + } + + public void markShadow(float radius, float dx, float dy, int color) { + if (startedRecord != null) { + startedRecord.hasShadow = radius > 0 && color != Color.TRANSPARENT; + startedRecord.shadowRadius = radius; + startedRecord.shadowX = dx; + startedRecord.shadowY = dy; + startedRecord.shadowColor = color; + } + } + + public void markVerticalOffset(float baselineShift) { + if (startedRecord != null) { + startedRecord.baselineShift = baselineShift; + } + } + + public void drawTextDecoration(Canvas canvas, Layout layout) { + TextDecorationSpan.StartMark lastSpan = null; + int line = -1; + for (int i = 0; i < textDecorationRecordCount; ++i) { + if (textDecorationPaint == null) { + textDecorationPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textDecorationPaint.setStyle(Paint.Style.STROKE); + } + TextDecorationRecord record = textDecorationRecords.get(i); + textDecorationPaint.setColor(record.color); + float left = record.left; + if (left < 0) { + if (lastSpan != record.mark) { + lastSpan = record.mark; + line = layout.getLineForOffset(record.indexOfChars); + } + left = layout.getLineLeft(line + record.lineOfSpan); + } + float right = record.right; + if (right < 0) { + if (lastSpan != record.mark) { + lastSpan = record.mark; + line = layout.getLineForOffset(record.indexOfChars); + } + right = layout.getLineRight(line + record.lineOfSpan); + } + if (record.underline) { + drawLineInternal(canvas, left, right, record.underlinePosition(), textDecorationPaint, record); + } + if (record.lineThrough) { + drawLineInternal(canvas, left, right, record.lineThroughPosition(), textDecorationPaint, record); + } + } + reset(); + } + + private void drawLineInternal(@NonNull Canvas canvas, float startX, float endX, float y, @NonNull Paint paint, + TextDecorationRecord record) { + float thickness = record.thickness(); + PathEffect pathEffect = null; + switch (record.style) { + case TextDecorationSpan.STYLE_DASHED: + // fall through + case TextDecorationSpan.STYLE_DOTTED: + pathEffect = buildPathEffect(thickness, record.style); + paint.setPathEffect(pathEffect); + // fall through + case TextDecorationSpan.STYLE_SOLID: + paint.setStrokeWidth(thickness); + boolean needDrawShadow = false; + if (record.hasShadow) { + if (pathEffect == null || isShadowLayerWithPathEffectSupported(canvas)) { + paint.setShadowLayer(record.shadowRadius, record.shadowX, record.shadowY, record.shadowColor); + } else { + needDrawShadow = isShadowLayerForNonTextSupported(canvas); + } + } + if (pathEffect == null || isPathEffectForLineSupported(canvas)) { + canvas.drawLine(startX, y, endX, y, paint); + } else { + if (reusablePath == null) { + reusablePath = new Path(); + } else { + reusablePath.rewind(); + } + reusablePath.moveTo(startX, y); + reusablePath.lineTo(endX, y); + canvas.drawPath(reusablePath, paint); + } + if (pathEffect != null) { + paint.setPathEffect(null); + if (needDrawShadow) { + // draw shadow after clear PathEffect + final int previousColor = paint.getColor(); + paint.setColor(Color.TRANSPARENT); + paint.setShadowLayer(record.shadowRadius, record.shadowX, record.shadowY, record.shadowColor); + canvas.drawLine(startX, y, endX, y, paint); + paint.setColor(previousColor); + } + } + break; + case TextDecorationSpan.STYLE_DOUBLE: + thickness *= 2 / 3f; + paint.setStrokeWidth(thickness); + if (record.hasShadow) { + paint.setShadowLayer(record.shadowRadius, record.shadowX, record.shadowY, record.shadowColor); + } + canvas.drawLine(startX, y - thickness, endX, y - thickness, paint); + canvas.drawLine(startX, y + thickness, endX, y + thickness, paint); + break; + default: + break; + } + if (record.hasShadow) { + paint.clearShadowLayer(); + } + } + + private boolean isShadowLayerWithPathEffectSupported(Canvas canvas) { + // when hardware acceleration is enabled: + // * ShadowLayer is not blurred with PathEffect on API Level >= 28 + // * ShadowLayer not supported for non-text on API Level < 28 + return !canvas.isHardwareAccelerated(); + } + + private boolean isPathEffectForLineSupported(Canvas canvas) { + // https://developer.android.com/topic/performance/hardware-accel#drawing-support + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P || !canvas.isHardwareAccelerated(); + } + + private boolean isShadowLayerForNonTextSupported(Canvas canvas) { + // https://developer.android.com/topic/performance/hardware-accel#drawing-support + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P || !canvas.isHardwareAccelerated(); + } + + private PathEffect buildPathEffect(float thickness, int style) { + int key = (int) Math.ceil(thickness); + PathEffect pathEffect = null; + if (style == TextDecorationSpan.STYLE_DASHED) { + if (dashedEffect == null) { + dashedEffect = new SparseArray<>(); + } else { + pathEffect = dashedEffect.get(key); + } + if (pathEffect == null) { + int fix = (int) PixelUtil.dp2px(5); + pathEffect = new DashPathEffect(new float[]{2 * key + fix, key + fix}, 0); + dashedEffect.put(key, pathEffect); + } + return pathEffect; + } else if (style == TextDecorationSpan.STYLE_DOTTED) { + if (dottedEffect == null) { + dottedEffect = new SparseArray<>(); + } else { + pathEffect = dottedEffect.get(key); + } + if (pathEffect == null) { + if (key <= 2) { + pathEffect = new DashPathEffect(new float[]{key, key}, 0); + } else { + Path circle = new Path(); + circle.addCircle(0, 0, key * 0.5f, Path.Direction.CW); + pathEffect = new PathDashPathEffect(circle, key * 2, key * 0.5f, + PathDashPathEffect.Style.TRANSLATE); + } + dottedEffect.put(key, pathEffect); + } + + } + return pathEffect; + } + + private static class TextDecorationRecord { + + // according to kStdUnderline_Thickness defined in aosp's Paint.h + private static final float THICKNESS = (1.0f / 18.0f); + // according to kStdUnderline_Offset defined in aosp's Paint.h + private static final float UNDERLINE_OFFSET = (1.0f / 9.0f); + // according to kStdStrikeThru_Offset defined in aosp's Paint.h + private static final float LINE_THROUGH_OFFSET = (-6.0f / 21.0f); + + TextDecorationSpan.StartMark mark; + int indexOfChars = -1; + int lineOfSpan; + float left = -1; + float right = -1; + float baseline; + float baselineShift; + + boolean underline; + boolean lineThrough; + int color; + int style; + float textSize; + + boolean hasShadow; + int shadowColor; + float shadowRadius; + float shadowX; + float shadowY; + + float thickness() { + return textSize * THICKNESS; + } + + float underlinePosition() { + return baseline + baselineShift + textSize * UNDERLINE_OFFSET; + } + + float lineThroughPosition() { + return baseline + baselineShift + textSize * LINE_THROUGH_OFFSET; + } + + void reset() { + mark = null; + indexOfChars = -1; + lineOfSpan = 0; + left = -1; + right = -1; + baseline = 0; + baselineShift = 0; + + underline = false; + lineThrough = false; + hasShadow = false; + } + } + + public interface LineMetrics { + + void setLineMetrics(TextLineMetricsHelper helper); + } + +} diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextShadowSpan.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextShadowSpan.java index b938f54c746..215eeab5ccc 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextShadowSpan.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextShadowSpan.java @@ -18,12 +18,13 @@ import android.text.TextPaint; import android.text.style.CharacterStyle; -public class TextShadowSpan extends CharacterStyle { +public class TextShadowSpan extends CharacterStyle implements TextLineMetricsHelper.LineMetrics { private final float mDx; private final float mDy; private final float mRadius; private final int mColor; + private TextLineMetricsHelper mHelper; public TextShadowSpan(float dx, float dy, float radius, int color) { mDx = dx; @@ -35,5 +36,13 @@ public TextShadowSpan(float dx, float dy, float radius, int color) { @Override public void updateDrawState(TextPaint textPaint) { textPaint.setShadowLayer(mRadius, mDx, mDy, mColor); + if (mHelper != null) { + mHelper.markShadow(mRadius, mDx, mDy, mColor); + } + } + + @Override + public void setLineMetrics(TextLineMetricsHelper helper) { + mHelper = helper; } } diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextVerticalAlignSpan.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextVerticalAlignSpan.java index 0f34f5a9fae..db705eb4db9 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextVerticalAlignSpan.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/text/TextVerticalAlignSpan.java @@ -19,45 +19,45 @@ import android.graphics.Paint.FontMetricsInt; import android.text.TextPaint; import android.text.style.CharacterStyle; -import com.tencent.renderer.node.TextVirtualNode; +import com.tencent.renderer.node.VirtualNode; -public class TextVerticalAlignSpan extends CharacterStyle { +public class TextVerticalAlignSpan extends CharacterStyle implements TextLineMetricsHelper.LineMetrics { private final FontMetricsInt mReusableFontMetricsInt = new FontMetricsInt(); private final String mVerticalAlign; - private int mLineTop; - private int mLineBottom; + private TextLineMetricsHelper mHelper; public TextVerticalAlignSpan(String verticalAlign) { this.mVerticalAlign = verticalAlign; } - public void setLineMetrics(int top, int bottom) { - mLineTop = top; - mLineBottom = bottom; + @Override + public void setLineMetrics(TextLineMetricsHelper helper) { + mHelper = helper; } @Override public void updateDrawState(TextPaint tp) { - if (mLineTop != 0 || mLineBottom != 0) { + if (mHelper != null && (mHelper.getLineTop() != 0 || mHelper.getLineBottom() != 0)) { final FontMetricsInt fmi = mReusableFontMetricsInt; switch (mVerticalAlign) { - case TextVirtualNode.V_ALIGN_TOP: + case VirtualNode.V_ALIGN_TOP: tp.getFontMetricsInt(fmi); - tp.baselineShift = mLineTop - fmi.top; + tp.baselineShift = mHelper.getLineTop() - fmi.top; break; - case TextVirtualNode.V_ALIGN_MIDDLE: + case VirtualNode.V_ALIGN_MIDDLE: tp.getFontMetricsInt(fmi); - tp.baselineShift = (mLineTop + mLineBottom - fmi.top - fmi.bottom) / 2; + tp.baselineShift = (mHelper.getLineTop() + mHelper.getLineBottom() - fmi.top - fmi.bottom) / 2; break; - case TextVirtualNode.V_ALIGN_BOTTOM: + case VirtualNode.V_ALIGN_BOTTOM: tp.getFontMetricsInt(fmi); - tp.baselineShift = mLineBottom - fmi.bottom; + tp.baselineShift = mHelper.getLineBottom() - fmi.bottom; break; - case TextVirtualNode.V_ALIGN_BASELINE: + case VirtualNode.V_ALIGN_BASELINE: default: break; } + mHelper.markVerticalOffset(tp.baselineShift); } } } diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/node/ImageVirtualNode.java b/renderer/native/android/src/main/java/com/tencent/renderer/node/ImageVirtualNode.java index f60827c171e..4955f8773b3 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/node/ImageVirtualNode.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/node/ImageVirtualNode.java @@ -25,23 +25,19 @@ import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.text.SpannableStringBuilder; - import android.text.style.ImageSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - import com.tencent.mtt.hippy.annotation.HippyControllerProps; import com.tencent.mtt.hippy.dom.node.NodeProps; import com.tencent.mtt.hippy.utils.ContextHolder; import com.tencent.mtt.hippy.utils.PixelUtil; import com.tencent.renderer.NativeRender; - import com.tencent.renderer.component.image.ImageDataSupplier; import com.tencent.renderer.component.image.ImageLoaderAdapter; import com.tencent.renderer.component.text.TextGestureSpan; import com.tencent.renderer.component.text.TextImageSpan; import java.util.List; -import java.util.Objects; public class ImageVirtualNode extends VirtualNode { @@ -60,8 +56,6 @@ public class ImageVirtualNode extends VirtualNode { protected float mMarginTop = Float.NaN; protected float mMarginRight = Float.NaN; protected float mMarginBottom = Float.NaN; - @Nullable - protected String mVerticalAlign; protected int mTintColor = Color.TRANSPARENT; protected int mBackgroundColor = Color.TRANSPARENT; @Nullable @@ -134,17 +128,6 @@ private int getValue(float primary, float secondary, float tertiary) { return 0; } - @Nullable - public String getVerticalAlign() { - if (mVerticalAlign != null) { - return mVerticalAlign; - } - if (mParent instanceof TextVirtualNode) { - return ((TextVirtualNode) mParent).getVerticalAlign(); - } - return null; - } - @NonNull protected TextImageSpan createImageSpan() { Drawable drawable = null; @@ -286,25 +269,7 @@ public void setVerticalAlignment(int alignment) { @SuppressWarnings("unused") @HippyControllerProps(name = NodeProps.VERTICAL_ALIGN, defaultType = HippyControllerProps.STRING) public void setVerticalAlign(String align) { - if (Objects.equals(mVerticalAlign, align)) { - return; - } - switch (align) { - case HippyControllerProps.DEFAULT: - // reset to default - mVerticalAlign = null; - break; - case TextVirtualNode.V_ALIGN_TOP: - case TextVirtualNode.V_ALIGN_MIDDLE: - case TextVirtualNode.V_ALIGN_BASELINE: - case TextVirtualNode.V_ALIGN_BOTTOM: - mVerticalAlign = align; - break; - default: - mVerticalAlign = TextVirtualNode.V_ALIGN_BASELINE; - break; - } - markDirty(); + super.setVerticalAlign(align); } @HippyControllerProps(name = NodeProps.OPACITY, defaultType = HippyControllerProps.NUMBER, defaultNumber = 1f) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/node/TextVirtualNode.java b/renderer/native/android/src/main/java/com/tencent/renderer/node/TextVirtualNode.java index 5dc68570856..9be83ec5047 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/node/TextVirtualNode.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/node/TextVirtualNode.java @@ -34,8 +34,6 @@ import android.text.style.AbsoluteSizeSpan; import android.text.style.BackgroundColorSpan; import android.text.style.ImageSpan; -import android.text.style.StrikethroughSpan; -import android.text.style.UnderlineSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -45,10 +43,12 @@ import com.tencent.mtt.hippy.utils.PixelUtil; import com.tencent.renderer.NativeRender; import com.tencent.renderer.component.text.FontAdapter; +import com.tencent.renderer.component.text.TextDecorationSpan; import com.tencent.renderer.component.text.TextForegroundColorSpan; import com.tencent.renderer.component.text.TextGestureSpan; import com.tencent.renderer.component.text.TextLetterSpacingSpan; import com.tencent.renderer.component.text.TextLineHeightSpan; +import com.tencent.renderer.component.text.TextLineMetricsHelper; import com.tencent.renderer.component.text.TextShadowSpan; import com.tencent.renderer.component.text.TextStyleSpan; import com.tencent.renderer.component.text.TextVerticalAlignSpan; @@ -64,14 +64,15 @@ public class TextVirtualNode extends VirtualNode { public static final String STRATEGY_SIMPLE = "simple"; public static final String STRATEGY_HIGH_QUALITY = "high_quality"; public static final String STRATEGY_BALANCED = "balanced"; - public final static String V_ALIGN_TOP = "top"; - public final static String V_ALIGN_MIDDLE = "middle"; - public final static String V_ALIGN_BASELINE = "baseline"; - public final static String V_ALIGN_BOTTOM = "bottom"; private static final int TEXT_SHADOW_COLOR_DEFAULT = 0x55000000; private static final String TEXT_DECORATION_UNDERLINE = "underline"; private static final String TEXT_DECORATION_LINE_THROUGH = "line-through"; + private static final String TEXT_DECORATION_DOUBLE = "double"; + private static final String TEXT_DECORATION_DOTTED = "dotted"; + private static final String TEXT_DECORATION_DASHED = "dashed"; + // an invisible mark for text decoration metrics + private static final String TEXT_DECORATION_MARK = "\u200b"; private static final String MODE_HEAD = "head"; private static final String MODE_MIDDLE = "middle"; private static final String MODE_TAIL = "tail"; @@ -100,6 +101,8 @@ public class TextVirtualNode extends VirtualNode { protected float mLastLayoutWidth = 0.0f; protected boolean mHasUnderlineTextDecoration = false; protected boolean mHasLineThroughTextDecoration = false; + protected int mTextDecorationColor = Color.TRANSPARENT; + protected int mTextDecorationStyle = TextDecorationSpan.STYLE_SOLID; protected boolean mEnableScale = false; protected String mEllipsizeMode = MODE_TAIL; protected String mBreakStrategy = STRATEGY_SIMPLE; @@ -119,8 +122,6 @@ public class TextVirtualNode extends VirtualNode { protected final FontAdapter mFontAdapter; @Nullable protected Layout mLayout; - @Nullable - protected String mVerticalAlign; protected int mBackgroundColor = Color.TRANSPARENT; public TextVirtualNode(int rootId, int id, int pid, int index, @@ -218,6 +219,41 @@ public void setTextDecorationLine(String textDecorationLine) { markDirty(); } + @SuppressWarnings("unused") + @HippyControllerProps(name = NodeProps.TEXT_DECORATION_COLOR, defaultType = HippyControllerProps.NUMBER) + public void setTextDecorationColor(int color) { + if (mTextDecorationColor == color) { + return; + } + mTextDecorationColor = color; + markDirty(); + } + + @SuppressWarnings("unused") + @HippyControllerProps(name = NodeProps.TEXT_DECORATION_STYLE, defaultType = HippyControllerProps.STRING) + public void setTextDecorationStyle(String style) { + int styleFlag; + switch (style) { + case TEXT_DECORATION_DOUBLE: + styleFlag = TextDecorationSpan.STYLE_DOUBLE; + break; + case TEXT_DECORATION_DOTTED: + styleFlag = TextDecorationSpan.STYLE_DOTTED; + break; + case TEXT_DECORATION_DASHED: + styleFlag = TextDecorationSpan.STYLE_DASHED; + break; + default: + styleFlag = TextDecorationSpan.STYLE_SOLID; + break; + } + if (mTextDecorationStyle == styleFlag) { + return; + } + mTextDecorationStyle = styleFlag; + markDirty(); + } + @SuppressWarnings({"unused", "rawtypes"}) @HippyControllerProps(name = TEXT_SHADOW_OFFSET, defaultType = HippyControllerProps.MAP) public void setTextShadowOffset(HashMap offsetMap) { @@ -411,6 +447,20 @@ protected void createSpanOperationImpl(@NonNull List ops, if (start > end) { return; } + if (mHasUnderlineTextDecoration || mHasLineThroughTextDecoration) { + TextDecorationSpan span = new TextDecorationSpan(mHasUnderlineTextDecoration, + mHasLineThroughTextDecoration, mTextDecorationColor, mTextDecorationStyle); + if (span.needSpecialDraw()) { + // inserting changes the position of SpanOperation that >= start, so it must be placed first + builder.insert(start, TEXT_DECORATION_MARK); + builder.append(TEXT_DECORATION_MARK); + ops.add(new SpanOperation(start, start + 1, new TextDecorationSpan.StartMark())); + ++start; + ++end; + ops.add(new SpanOperation(end, end + 1, new TextDecorationSpan.EndMark())); + } + ops.add(new SpanOperation(start, end, span, SpanOperation.PRIORITY_LOWEST)); + } String verticalAlign = getVerticalAlign(); if (verticalAlign != null && !V_ALIGN_BASELINE.equals(verticalAlign)) { TextVerticalAlignSpan span = new TextVerticalAlignSpan(verticalAlign); @@ -434,12 +484,6 @@ protected void createSpanOperationImpl(@NonNull List ops, } ops.add(new SpanOperation(start, end, new AbsoluteSizeSpan(size))); ops.add(new SpanOperation(start, end, new TextStyleSpan(mItalic, mFontWeight, mFontFamily, mFontAdapter))); - if (mHasUnderlineTextDecoration) { - ops.add(new SpanOperation(start, end, new UnderlineSpan())); - } - if (mHasLineThroughTextDecoration) { - ops.add(new SpanOperation(start, end, new StrikethroughSpan())); - } if (mShadowOffsetDx != 0 || mShadowOffsetDy != 0) { int color = colorWithOpacity(mShadowColor, opacity); if (color != Color.TRANSPARENT) { @@ -466,7 +510,20 @@ protected void createSpanOperationImpl(@NonNull List ops, } ops.add(new SpanOperation(paragraphStart, paragraphEnd, new TextLineHeightSpan(lh))); } + if (needLineMetrics(ops)) { + ops.add(new SpanOperation(paragraphStart, paragraphEnd, new TextLineMetricsHelper())); + } + } + } + + private boolean needLineMetrics(@NonNull List ops) { + for (SpanOperation op : ops) { + Object span = op.getSpan(); + if (span instanceof TextVerticalAlignSpan || span instanceof TextDecorationSpan.StartMark) { + return true; + } } + return false; } public static int colorWithOpacity(int color, float opacity) { @@ -530,12 +587,13 @@ protected Layout createLayout(final float width, final FlexMeasureMode widthMode CharSequence layoutText = layout.getText(); if (layoutText instanceof Spanned) { Spanned spanned = (Spanned) layoutText; - TextVerticalAlignSpan[] spans = spanned.getSpans(0, spanned.length(), TextVerticalAlignSpan.class); - for (TextVerticalAlignSpan span : spans) { - int offset = spanned.getSpanStart(span); - int line = layout.getLineForOffset(offset); - int baseline = layout.getLineBaseline(line); - span.setLineMetrics(layout.getLineTop(line) - baseline, layout.getLineBottom(line) - baseline); + TextLineMetricsHelper helper = getTextLineMetricsHelper(spanned); + if (helper != null) { + setTextLineMetricsHelper(spanned, TextVerticalAlignSpan.class, helper); + setTextLineMetricsHelper(spanned, TextDecorationSpan.StartMark.class, helper); + setTextLineMetricsHelper(spanned, TextDecorationSpan.class, helper); + setTextLineMetricsHelper(spanned, TextDecorationSpan.EndMark.class, helper); + setTextLineMetricsHelper(spanned, TextShadowSpan.class, helper); } } mLayout = layout; @@ -543,6 +601,21 @@ protected Layout createLayout(final float width, final FlexMeasureMode widthMode return layout; } + private TextLineMetricsHelper getTextLineMetricsHelper(Spanned spanned) { + TextLineMetricsHelper[] spans = spanned.getSpans(0, 0, TextLineMetricsHelper.class); + return spans != null && spans.length > 0 ? spans[0] : null; + } + + private void setTextLineMetricsHelper(Spanned spanned, + Class type, TextLineMetricsHelper helper) { + T[] spans = spanned.getSpans(0, spanned.length(), type); + if (spans != null) { + for (T span : spans) { + span.setLineMetrics(helper); + } + } + } + private TextPaint getTextPaint() { if (TextUtils.isEmpty(mText)) { if (mTextPaintForEmpty == null) { @@ -769,33 +842,7 @@ public void setBackgroundColor(int backgroundColor) { @HippyControllerProps(name = NodeProps.VERTICAL_ALIGN, defaultType = HippyControllerProps.STRING) public void setVerticalAlign(String align) { - switch (align) { - case HippyControllerProps.DEFAULT: - // reset to default - mVerticalAlign = null; - break; - case V_ALIGN_TOP: - case V_ALIGN_MIDDLE: - case V_ALIGN_BASELINE: - case V_ALIGN_BOTTOM: - mVerticalAlign = align; - break; - default: - mVerticalAlign = V_ALIGN_BASELINE; - break; - } - markDirty(); - } - - @Nullable - public String getVerticalAlign() { - if (mVerticalAlign != null) { - return mVerticalAlign; - } - if (mParent instanceof TextVirtualNode) { - return ((TextVirtualNode) mParent).getVerticalAlign(); - } - return null; + super.setVerticalAlign(align); } @HippyControllerProps(name = NodeProps.OPACITY, defaultType = HippyControllerProps.NUMBER, defaultNumber = 1f) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/node/VirtualNode.java b/renderer/native/android/src/main/java/com/tencent/renderer/node/VirtualNode.java index f5c9cc72ba4..4ea52fa5a38 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/node/VirtualNode.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/node/VirtualNode.java @@ -18,16 +18,20 @@ import android.text.Spannable; import android.text.SpannableStringBuilder; - import android.text.style.ImageSpan; import androidx.annotation.NonNull; import androidx.annotation.Nullable; - +import com.tencent.mtt.hippy.annotation.HippyControllerProps; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public abstract class VirtualNode { + public static final String V_ALIGN_TOP = "top"; + public static final String V_ALIGN_MIDDLE = "middle"; + public static final String V_ALIGN_BASELINE = "baseline"; + public static final String V_ALIGN_BOTTOM = "bottom"; protected final int mRootId; protected final int mId; protected final int mPid; @@ -39,6 +43,8 @@ public abstract class VirtualNode { protected VirtualNode mParent; @Nullable protected List mEventTypes; + @Nullable + protected String mVerticalAlign; protected float mOpacity = 1f; public VirtualNode(int rootId, int id, int pid, int index) { @@ -138,6 +144,39 @@ public int getChildCount() { return mChildren.size(); } + public void setVerticalAlign(String align) { + if (Objects.equals(mVerticalAlign, align)) { + return; + } + switch (align) { + case HippyControllerProps.DEFAULT: + // reset to default + mVerticalAlign = null; + break; + case V_ALIGN_TOP: + case V_ALIGN_MIDDLE: + case V_ALIGN_BASELINE: + case V_ALIGN_BOTTOM: + mVerticalAlign = align; + break; + default: + mVerticalAlign = V_ALIGN_BASELINE; + break; + } + markDirty(); + } + + @Nullable + public String getVerticalAlign() { + if (mVerticalAlign != null) { + return mVerticalAlign; + } + if (mParent != null) { + return mParent.getVerticalAlign(); + } + return null; + } + public void setOpacity(float opacity) { opacity = Math.min(Math.max(0, opacity), 1); if (opacity != mOpacity) { @@ -171,6 +210,10 @@ protected static class SpanOperation { mPriority = priority; } + public Object getSpan() { + return mWhat; + } + public void execute(SpannableStringBuilder builder) { int spanFlags; if (mWhat instanceof ImageSpan) {