Skip to content

Commit 31f37ff

Browse files
feat(android): text alignment (#602)
# Summary Adds text alignment for Android *(Note: `justify` alignment is not natively supported for single spans on Android and will not work).* ## Test Plan Run example app and play with `textAlignment` when different styles are toggled. Check if paragraphs/lists with different `textAlignment` than auto are properly parsed and contain `style="text-align:center|right"` inside HTML ## Screenshots / Videos https://github.com/user-attachments/assets/089543ad-93e4-4842-8b87-95316a3cb747 ## Compatibility | OS | Implemented | | ------- | :---------: | | iOS | ❌ | | Android | ✅ | ## Checklist - [x] E2E tests are passing - [x] Required E2E tests have been added (if applicable)
1 parent 4749460 commit 31f37ff

22 files changed

Lines changed: 646 additions & 49 deletions

.maestro/enrichedInput/flows/paragraph_styles_alignment_visual.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
appId: swmansion.enriched.example
2-
tags:
3-
- ios-only
42
---
53
# Visually validates text alignment rendering
64
- launchApp
27.2 KB
Loading
30 KB
Loading

android/src/main/java/com/swmansion/enriched/common/parser/EnrichedParser.java

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
package com.swmansion.enriched.common.parser;
22

33
import android.text.Editable;
4-
import android.text.Layout;
54
import android.text.Spannable;
65
import android.text.SpannableStringBuilder;
76
import android.text.Spanned;
87
import android.text.TextUtils;
9-
import android.text.style.AlignmentSpan;
108
import android.text.style.ParagraphStyle;
119
import com.swmansion.enriched.common.EnrichedConstants;
10+
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan;
1211
import com.swmansion.enriched.common.spans.EnrichedBoldSpan;
1312
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan;
1413
import com.swmansion.enriched.common.spans.EnrichedCodeBlockSpan;
@@ -30,11 +29,14 @@
3029
import com.swmansion.enriched.common.spans.interfaces.EnrichedBlockSpan;
3130
import com.swmansion.enriched.common.spans.interfaces.EnrichedInlineSpan;
3231
import com.swmansion.enriched.common.spans.interfaces.EnrichedParagraphSpan;
32+
import com.swmansion.enriched.common.spans.interfaces.EnrichedSpan;
3333
import com.swmansion.enriched.common.spans.interfaces.EnrichedZeroWidthSpaceSpan;
3434
import java.io.IOException;
3535
import java.io.StringReader;
3636
import java.util.HashMap;
3737
import java.util.Map;
38+
import java.util.regex.Matcher;
39+
import java.util.regex.Pattern;
3840
import org.ccil.cowan.tagsoup.HTMLSchema;
3941
import org.ccil.cowan.tagsoup.Parser;
4042
import org.xml.sax.Attributes;
@@ -86,8 +88,8 @@ public static String toHtml(Spanned text) {
8688
String normalizedBlockQuote =
8789
normalizedCodeBlock.replaceAll("</blockquote>\\n<br>", "</blockquote>");
8890

89-
// replace empty <p></p> into <br> in the very end
90-
String normalizedHtml = normalizedBlockQuote.replaceAll("<p></p>", "<br>");
91+
// Replace empty <p> tags (with or without style attributes) with <br>
92+
String normalizedHtml = normalizedBlockQuote.replaceAll("<p[^>]*></p>", "<br>");
9193

9294
return "<html>\n" + normalizedHtml + "</html>";
9395
}
@@ -136,6 +138,14 @@ private static void withinDiv(StringBuilder out, Spanned text, int start, int en
136138
}
137139
}
138140

141+
private static String getAlignmentStyleAttr(Spanned text, int start, int end) {
142+
EnrichedAlignmentSpan[] spans = text.getSpans(start, end, EnrichedAlignmentSpan.class);
143+
if (spans.length == 0) return "";
144+
String cssValue = spans[0].getCssValue();
145+
if (cssValue.equals("auto")) return "";
146+
return " style=\"text-align: " + cssValue + "\"";
147+
}
148+
139149
private static String getBlockTag(EnrichedParagraphSpan[] spans) {
140150
for (EnrichedParagraphSpan span : spans) {
141151
if (span instanceof EnrichedUnorderedListSpan) {
@@ -213,15 +223,17 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int
213223
if (isUlListItem && !isInUlList) {
214224
// Current paragraph is the first item in a list
215225
isInUlList = true;
216-
out.append("<ul").append(">\n");
226+
out.append("<ul").append(getAlignmentStyleAttr(text, i, next)).append(">\n");
217227
} else if (isOlListItem && !isInOlList) {
218228
// Current paragraph is the first item in a list
219229
isInOlList = true;
220-
out.append("<ol").append(">\n");
230+
out.append("<ol").append(getAlignmentStyleAttr(text, i, next)).append(">\n");
221231
} else if (isCheckboxListItem && !isInCheckboxList) {
222232
// Current paragraph is the first item in a list
223233
isInCheckboxList = true;
224-
out.append("<ul data-type=\"checkbox\">\n");
234+
out.append("<ul data-type=\"checkbox\"")
235+
.append(getAlignmentStyleAttr(text, i, next))
236+
.append(">\n");
225237
}
226238

227239
boolean isList = isUlListItem || isOlListItem || isCheckboxListItem;
@@ -230,6 +242,11 @@ private static void withinBlock(StringBuilder out, Spanned text, int start, int
230242
out.append("<");
231243
out.append(tagType);
232244

245+
// Add alignment style to non-list paragraph/heading tags
246+
if (!isList) {
247+
out.append(getAlignmentStyleAttr(text, i, next));
248+
}
249+
233250
if (isCheckboxListItem) {
234251
EnrichedCheckboxListSpan[] checkboxSpans =
235252
text.getSpans(i, next, EnrichedCheckboxListSpan.class);
@@ -395,6 +412,24 @@ class HtmlToSpannedConverter<T> implements ContentHandler {
395412
private static Boolean isInOrderedList = false;
396413
private static Boolean isInCheckboxList = false;
397414
private static Boolean isEmptyTag = false;
415+
private static String currentListAlignmentCssValue = null;
416+
417+
private static final Pattern CSS_ALIGNMENT_PATTERN =
418+
Pattern.compile("text-align\\s*:\\s*(left|center|right)", Pattern.CASE_INSENSITIVE);
419+
420+
private static String parseCssAlignmentValue(Attributes attributes) {
421+
String style = attributes.getValue("", "style");
422+
if (style == null) return null;
423+
Matcher m = CSS_ALIGNMENT_PATTERN.matcher(style);
424+
return m.find() ? m.group(1).toLowerCase() : null;
425+
}
426+
427+
private static void pushAlignmentMark(Editable text, Attributes attributes) {
428+
String cssValue = parseCssAlignmentValue(attributes);
429+
if (cssValue != null) {
430+
start(text, new Alignment(cssValue));
431+
}
432+
}
398433

399434
public HtmlToSpannedConverter(
400435
String source, T style, Parser parser, EnrichedSpanFactory<T> spanFactory) {
@@ -448,9 +483,24 @@ public Spanned convert() {
448483
int end = mSpannableStringBuilder.getSpanEnd(zeroWidthSpaceSpan);
449484

450485
if (mSpannableStringBuilder.charAt(start) != EnrichedConstants.ZWS) {
451-
// Insert zero-width space character at the start if it's not already present.
486+
// Collect spans before inserting ZWS. SPAN_EXCLUSIVE_EXCLUSIVE spans will
487+
// shift to start+1. We must re-anchor them back to `start` to prevent
488+
// the loop from processing them again and inserting duplicate ZWS.
489+
EnrichedSpan[] colocated =
490+
mSpannableStringBuilder.getSpans(start, start + 1, EnrichedSpan.class);
491+
452492
mSpannableStringBuilder.insert(start, EnrichedConstants.ZWS_STRING);
453-
end++; // Adjust end position due to insertion.
493+
end++;
494+
495+
for (EnrichedSpan span : colocated) {
496+
if (span == zeroWidthSpaceSpan) continue;
497+
// Only re-anchor spans that actually shifted.
498+
// Skip overlapping or INCLUSIVE spans that kept their original start.
499+
if (mSpannableStringBuilder.getSpanStart(span) != start + 1) continue;
500+
int spanEnd = mSpannableStringBuilder.getSpanEnd(span);
501+
mSpannableStringBuilder.removeSpan(span);
502+
mSpannableStringBuilder.setSpan(span, start, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
503+
}
454504
}
455505

456506
mSpannableStringBuilder.removeSpan(zeroWidthSpaceSpan);
@@ -468,14 +518,17 @@ private void handleStartTag(String tag, Attributes attributes) {
468518
} else if (tag.equalsIgnoreCase("p")) {
469519
isEmptyTag = true;
470520
startBlockElement(mSpannableStringBuilder);
521+
pushAlignmentMark(mSpannableStringBuilder, attributes);
471522
} else if (tag.equalsIgnoreCase("ul")) {
472523
isInOrderedList = false;
473524
String dataType = attributes.getValue("", "data-type");
474525
isInCheckboxList = "checkbox".equals(dataType);
526+
currentListAlignmentCssValue = parseCssAlignmentValue(attributes);
475527
startBlockElement(mSpannableStringBuilder);
476528
} else if (tag.equalsIgnoreCase("ol")) {
477529
isInOrderedList = true;
478530
currentOrderedListItemIndex = 0;
531+
currentListAlignmentCssValue = parseCssAlignmentValue(attributes);
479532
startBlockElement(mSpannableStringBuilder);
480533
} else if (tag.equalsIgnoreCase("li")) {
481534
isEmptyTag = true;
@@ -500,16 +553,22 @@ private void handleStartTag(String tag, Attributes attributes) {
500553
start(mSpannableStringBuilder, new Strikethrough());
501554
} else if (tag.equalsIgnoreCase("h1")) {
502555
startHeading(mSpannableStringBuilder, 1);
556+
pushAlignmentMark(mSpannableStringBuilder, attributes);
503557
} else if (tag.equalsIgnoreCase("h2")) {
504558
startHeading(mSpannableStringBuilder, 2);
559+
pushAlignmentMark(mSpannableStringBuilder, attributes);
505560
} else if (tag.equalsIgnoreCase("h3")) {
506561
startHeading(mSpannableStringBuilder, 3);
562+
pushAlignmentMark(mSpannableStringBuilder, attributes);
507563
} else if (tag.equalsIgnoreCase("h4")) {
508564
startHeading(mSpannableStringBuilder, 4);
565+
pushAlignmentMark(mSpannableStringBuilder, attributes);
509566
} else if (tag.equalsIgnoreCase("h5")) {
510567
startHeading(mSpannableStringBuilder, 5);
568+
pushAlignmentMark(mSpannableStringBuilder, attributes);
511569
} else if (tag.equalsIgnoreCase("h6")) {
512570
startHeading(mSpannableStringBuilder, 6);
571+
pushAlignmentMark(mSpannableStringBuilder, attributes);
513572
} else if (tag.equalsIgnoreCase("img")) {
514573
// Image content means the current tag is not empty (e.g. <li><img .../></li>).
515574
isEmptyTag = false;
@@ -525,9 +584,13 @@ private void handleEndTag(String tag) {
525584
if (tag.equalsIgnoreCase("br")) {
526585
handleBr(mSpannableStringBuilder);
527586
} else if (tag.equalsIgnoreCase("p")) {
528-
endBlockElement(mSpannableStringBuilder);
587+
endBlockElement(mSpannableStringBuilder, mSpanFactory);
529588
} else if (tag.equalsIgnoreCase("ul")) {
530-
endBlockElement(mSpannableStringBuilder);
589+
currentListAlignmentCssValue = null;
590+
endBlockElement(mSpannableStringBuilder, mSpanFactory);
591+
} else if (tag.equalsIgnoreCase("ol")) {
592+
currentListAlignmentCssValue = null;
593+
endBlockElement(mSpannableStringBuilder, mSpanFactory);
531594
} else if (tag.equalsIgnoreCase("li")) {
532595
endLi(mSpannableStringBuilder, mStyle, mSpanFactory);
533596
} else if (tag.equalsIgnoreCase("b")) {
@@ -585,15 +648,15 @@ private static void startBlockElement(Editable text) {
585648
start(text, new Newline(1));
586649
}
587650

588-
private static void endBlockElement(Editable text) {
651+
private static <T> void endBlockElement(Editable text, EnrichedSpanFactory<T> spanFactory) {
589652
Newline n = getLast(text, Newline.class);
590653
if (n != null) {
591654
appendNewlines(text, n.mNumNewlines);
592655
text.removeSpan(n);
593656
}
594657
Alignment a = getLast(text, Alignment.class);
595658
if (a != null) {
596-
setSpanFromMark(text, a, new AlignmentSpan.Standard(a.mAlignment));
659+
setParagraphSpanFromMark(text, a, spanFactory.createAlignmentSpan(a.mCssValue));
597660
}
598661
}
599662

@@ -604,6 +667,10 @@ private static void handleBr(Editable text) {
604667
private void startLi(Editable text, Attributes attributes) {
605668
startBlockElement(text);
606669

670+
if (currentListAlignmentCssValue != null) {
671+
start(text, new Alignment(currentListAlignmentCssValue));
672+
}
673+
607674
if (isInOrderedList) {
608675
currentOrderedListItemIndex++;
609676
start(text, new List("ordered", currentOrderedListItemIndex, false));
@@ -616,7 +683,7 @@ private void startLi(Editable text, Attributes attributes) {
616683
}
617684

618685
private static <T> void endLi(Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
619-
endBlockElement(text);
686+
endBlockElement(text, spanFactory);
620687

621688
List l = getLast(text, List.class);
622689
if (l != null) {
@@ -629,7 +696,7 @@ private static <T> void endLi(Editable text, T style, EnrichedSpanFactory<T> spa
629696
}
630697
}
631698

632-
endBlockElement(text);
699+
endBlockElement(text, spanFactory);
633700
}
634701

635702
private void startBlockquote(Editable text) {
@@ -639,7 +706,7 @@ private void startBlockquote(Editable text) {
639706

640707
private static <T> void endBlockquote(
641708
Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
642-
endBlockElement(text);
709+
endBlockElement(text, spanFactory);
643710
Blockquote last = getLast(text, Blockquote.class);
644711
setParagraphSpanFromMark(text, last, spanFactory.createBlockQuoteSpan(style));
645712
}
@@ -650,7 +717,7 @@ private void startCodeBlock(Editable text) {
650717
}
651718

652719
private static <T> void endCodeBlock(Editable text, T style, EnrichedSpanFactory<T> spanFactory) {
653-
endBlockElement(text);
720+
endBlockElement(text, spanFactory);
654721
CodeBlock last = getLast(text, CodeBlock.class);
655722
setParagraphSpanFromMark(text, last, spanFactory.createCodeBlockSpan(style));
656723
}
@@ -684,7 +751,7 @@ private void startHeading(Editable text, int level) {
684751

685752
private static <T> void endHeading(
686753
Editable text, T style, EnrichedSpanFactory<T> spanFactory, int level) {
687-
endBlockElement(text);
754+
endBlockElement(text, spanFactory);
688755

689756
switch (level) {
690757
case 1:
@@ -958,10 +1025,10 @@ public Newline(int numNewlines) {
9581025
}
9591026

9601027
private static class Alignment {
961-
private final Layout.Alignment mAlignment;
1028+
final String mCssValue;
9621029

963-
public Alignment(Layout.Alignment alignment) {
964-
mAlignment = alignment;
1030+
public Alignment(String cssValue) {
1031+
this.mCssValue = cssValue;
9651032
}
9661033
}
9671034
}

android/src/main/java/com/swmansion/enriched/common/parser/EnrichedSpanFactory.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.swmansion.enriched.common.parser
22

3+
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
34
import com.swmansion.enriched.common.spans.EnrichedBlockQuoteSpan
45
import com.swmansion.enriched.common.spans.EnrichedBoldSpan
56
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan
@@ -21,6 +22,8 @@ import com.swmansion.enriched.common.spans.EnrichedUnderlineSpan
2122
import com.swmansion.enriched.common.spans.EnrichedUnorderedListSpan
2223

2324
interface EnrichedSpanFactory<T> {
25+
fun createAlignmentSpan(cssValue: String): EnrichedAlignmentSpan
26+
2427
fun createBoldSpan(style: T): EnrichedBoldSpan
2528

2629
fun createItalicSpan(style: T): EnrichedItalicSpan
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.swmansion.enriched.common.spans
2+
3+
import android.text.Layout
4+
import android.text.style.AlignmentSpan
5+
import com.swmansion.enriched.common.spans.interfaces.EnrichedZeroWidthSpaceSpan
6+
7+
open class EnrichedAlignmentSpan(
8+
val cssValue: String,
9+
) : AlignmentSpan.Standard(cssValueToLayoutAlignment(cssValue)),
10+
EnrichedZeroWidthSpaceSpan {
11+
companion object {
12+
fun cssValueToLayoutAlignment(cssValue: String): Layout.Alignment =
13+
when (cssValue) {
14+
"center" -> Layout.Alignment.ALIGN_CENTER
15+
"right" -> Layout.Alignment.ALIGN_OPPOSITE
16+
else -> Layout.Alignment.ALIGN_NORMAL
17+
}
18+
}
19+
}

android/src/main/java/com/swmansion/enriched/text/EnrichedTextSpanFactory.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.enriched.text
22

33
import com.swmansion.enriched.common.parser.EnrichedSpanFactory
4+
import com.swmansion.enriched.text.spans.EnrichedTextAlignmentSpan
45
import com.swmansion.enriched.text.spans.EnrichedTextBlockQuoteSpan
56
import com.swmansion.enriched.text.spans.EnrichedTextBoldSpan
67
import com.swmansion.enriched.text.spans.EnrichedTextCheckboxListSpan
@@ -22,6 +23,8 @@ import com.swmansion.enriched.text.spans.EnrichedTextUnderlineSpan
2223
import com.swmansion.enriched.text.spans.EnrichedTextUnorderedListSpan
2324

2425
class EnrichedTextSpanFactory : EnrichedSpanFactory<EnrichedTextStyle> {
26+
override fun createAlignmentSpan(cssValue: String) = EnrichedTextAlignmentSpan(cssValue)
27+
2528
override fun createBoldSpan(style: EnrichedTextStyle) = EnrichedTextBoldSpan(style)
2629

2730
override fun createItalicSpan(style: EnrichedTextStyle) = EnrichedTextItalicSpan(style)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.swmansion.enriched.text.spans
2+
3+
import com.swmansion.enriched.common.spans.EnrichedAlignmentSpan
4+
import com.swmansion.enriched.text.EnrichedTextStyle
5+
import com.swmansion.enriched.text.spans.interfaces.EnrichedTextSpan
6+
7+
class EnrichedTextAlignmentSpan(
8+
cssValue: String,
9+
) : EnrichedAlignmentSpan(cssValue),
10+
EnrichedTextSpan {
11+
override val dependsOnHtmlStyle = false
12+
13+
override fun rebuildWithStyle(style: EnrichedTextStyle) = this
14+
}

android/src/main/java/com/swmansion/enriched/textinput/EnrichedTextInputSpannableFactory.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package com.swmansion.enriched.textinput
22

33
import com.swmansion.enriched.common.parser.EnrichedSpanFactory
4-
import com.swmansion.enriched.common.spans.EnrichedCheckboxListSpan
54
import com.swmansion.enriched.common.spans.EnrichedImageSpan
5+
import com.swmansion.enriched.textinput.spans.EnrichedInputAlignmentSpan
66
import com.swmansion.enriched.textinput.spans.EnrichedInputBlockQuoteSpan
77
import com.swmansion.enriched.textinput.spans.EnrichedInputBoldSpan
88
import com.swmansion.enriched.textinput.spans.EnrichedInputCheckboxListSpan
@@ -25,6 +25,8 @@ import com.swmansion.enriched.textinput.spans.EnrichedInputUnorderedListSpan
2525
import com.swmansion.enriched.textinput.styles.HtmlStyle
2626

2727
class EnrichedTextInputSpannableFactory : EnrichedSpanFactory<HtmlStyle> {
28+
override fun createAlignmentSpan(cssValue: String) = EnrichedInputAlignmentSpan(cssValue)
29+
2830
override fun createBoldSpan(style: HtmlStyle) = EnrichedInputBoldSpan(style)
2931

3032
override fun createItalicSpan(style: HtmlStyle) = EnrichedInputItalicSpan(style)

0 commit comments

Comments
 (0)