diff --git a/android/modules/ui/src/java/ti/modules/titanium/ui/AttributedStringProxy.java b/android/modules/ui/src/java/ti/modules/titanium/ui/AttributedStringProxy.java index fa580437b31..7036ca8b1de 100644 --- a/android/modules/ui/src/java/ti/modules/titanium/ui/AttributedStringProxy.java +++ b/android/modules/ui/src/java/ti/modules/titanium/ui/AttributedStringProxy.java @@ -6,6 +6,7 @@ */ package ti.modules.titanium.ui; +import java.lang.reflect.Method; import java.util.HashMap; import org.appcelerator.kroll.KrollDict; @@ -19,9 +20,11 @@ import android.app.Activity; import android.graphics.Paint; import android.graphics.Typeface; +import android.os.Build; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableString; +import android.text.TextPaint; import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.BackgroundColorSpan; @@ -40,6 +43,65 @@ public class AttributedStringProxy extends KrollProxy { private static final String TAG = "AttributedString"; + private static final class UnderlineColorSpan extends UnderlineSpan + { + private final int color; + private final float thickness; + + public UnderlineColorSpan(final int color, final float thickness) + { + this.color = color; + this.thickness = thickness; + } + + public UnderlineColorSpan(final int color) + { + this(color, 2.0f); + } + + @Override + public void updateDrawState(final TextPaint ds) + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ds.underlineColor = this.color; + ds.underlineThickness = this.thickness; + } else { + try { + final Method setUnderlineText = + TextPaint.class.getMethod("setUnderlineText", Integer.TYPE, Float.TYPE); + setUnderlineText.invoke(ds, this.color, this.thickness); + } catch (final Exception e) { + + // Fallback to default underline behavior. + ds.setUnderlineText(true); + } + } + } + } + + private static final class URLColorSpan extends URLSpan + { + private boolean underline = true; + + public URLColorSpan(String url) + { + super(url); + } + + public void setUnderline(boolean underline) + { + this.underline = underline; + } + + @Override + public void updateDrawState(final TextPaint ds) + { + super.updateDrawState(ds); + + ds.setUnderlineText(underline); + } + } + public AttributedStringProxy() { } @@ -199,6 +261,21 @@ public static Bundle toSpannableInBundle(AttributedStringProxy attrString, Activ spannableText.setSpan(new StrikethroughSpan(), range[0], range[0] + range[1], Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); break; + case UIModule.ATTRIBUTE_UNDERLINE_COLOR: + final UnderlineColorSpan underlineColorSpan = new UnderlineColorSpan( + TiConvert.toColor(TiConvert.toString(attrValue))); + + spannableText.setSpan(underlineColorSpan, range[0], range[0] + range[1], + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + final URLColorSpan[] urlSpans = spannableText.getSpans( + range[0], range[0] + range[1], URLColorSpan.class); + for (final URLColorSpan urlSpan : urlSpans) { + + // Disable link underline, override with our underline color. + urlSpan.setUnderline(false); + } + break; case UIModule.ATTRIBUTE_UNDERLINES_STYLE: spannableText.setSpan(new UnderlineSpan(), range[0], range[0] + range[1], Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); @@ -213,9 +290,19 @@ public static Bundle toSpannableInBundle(AttributedStringProxy attrString, Activ break; case UIModule.ATTRIBUTE_LINK: if (attrValue != null) { - spannableText.setSpan(new URLSpan(TiConvert.toString(attrValue)), range[0], - range[0] + range[1], - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + final URLColorSpan urlColorSpan = + new URLColorSpan(TiConvert.toString(attrValue)); + + spannableText.setSpan(urlColorSpan, range[0], range[0] + range[1], + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + + final UnderlineColorSpan[] underlineSpans = spannableText.getSpans( + range[0], range[0] + range[1], UnderlineColorSpan.class); + if (underlineSpans.length > 0) { + + // Disable link underline, allow override with our underline color. + urlColorSpan.setUnderline(false); + } } results.putBoolean(TiC.PROPERTY_HAS_LINK, true); break; diff --git a/tests/Resources/android/snapshots/attributedString_coloredLink@2x.png b/tests/Resources/android/snapshots/attributedString_coloredLink@2x.png new file mode 100644 index 00000000000..d3ca7bff497 Binary files /dev/null and b/tests/Resources/android/snapshots/attributedString_coloredLink@2x.png differ diff --git a/tests/Resources/ios/snapshots/attributedString_coloredLink@2x~ipad.png b/tests/Resources/ios/snapshots/attributedString_coloredLink@2x~ipad.png new file mode 100644 index 00000000000..dc5791a53e0 Binary files /dev/null and b/tests/Resources/ios/snapshots/attributedString_coloredLink@2x~ipad.png differ diff --git a/tests/Resources/ios/snapshots/attributedString_coloredLink@3x~iphone.png b/tests/Resources/ios/snapshots/attributedString_coloredLink@3x~iphone.png new file mode 100644 index 00000000000..d33d094f11d Binary files /dev/null and b/tests/Resources/ios/snapshots/attributedString_coloredLink@3x~iphone.png differ diff --git a/tests/Resources/ti.ui.attributedstring.test.js b/tests/Resources/ti.ui.attributedstring.test.js index 079953d1758..3da95cca697 100644 --- a/tests/Resources/ti.ui.attributedstring.test.js +++ b/tests/Resources/ti.ui.attributedstring.test.js @@ -4,10 +4,14 @@ * Licensed under the terms of the Apache Public License * Please see the LICENSE included with this distribution for details. */ +/* global OS_ANDROID, OS_IOS, OS_VERSION_MAJOR */ /* eslint-env mocha */ /* eslint no-unused-expressions: "off" */ 'use strict'; const should = require('./utilities/assertions'); +const utilities = require('./utilities/utilities'); + +const isCI = Ti.App.Properties.getBool('isCI', false); describe('Titanium.UI', () => { it('#createAttributedString()', () => { @@ -65,4 +69,42 @@ describe('Titanium.UI.AttributedString', function () { }); should(attributedString.attributes.length).be.eql(2); }); + + it('colored link', () => { + // FIXME: Does not honour scale correctly on macOS: https://jira.appcelerator.org/browse/TIMOB-28261 + if (isCI && utilities.isMacOS() && OS_VERSION_MAJOR < 11) { + return; + } + + const view = Ti.UI.createView({ + width: '960px', + height: '220px' + }); + const label = Ti.UI.createLabel({ + attributedString: Ti.UI.createAttributedString({ + text: 'Check out the Appcelerator Developer Portal', + attributes: [ + { + type: Ti.UI.ATTRIBUTE_LINK, + value: 'https://developer.appcelerator.com', + range: [ 14, 29 ] + }, + { + type: Ti.UI.ATTRIBUTE_FOREGROUND_COLOR, + value: 'purple', + range: [ 14, 29 ] + }, + { + type: Ti.UI.ATTRIBUTE_UNDERLINE_COLOR, + value: 'orange', + range: [ 14, 29 ] + } + ] + }) + }); + + view.add(label); + + should(view).matchImage('snapshots/attributedString_coloredLink.png', { maxPixelMismatch: OS_IOS ? 2 : 0 }); // 2 pixels differ on actual iPhone + }); });