Fix bug preventing clickable elements within a Spannable working.
Bug: 189985619
Test: ./gradlew :wear:tiles:tiles-renderer:build
Relnote: "Fixed bug which prevented clickable element in a Spannable from
being clicked."
Change-Id: Iacc262c7ea07a42507171f42060319f204be537c
diff --git a/wear/tiles/tiles-renderer/src/main/java/androidx/wear/tiles/renderer/internal/TileRendererInternal.java b/wear/tiles/tiles-renderer/src/main/java/androidx/wear/tiles/renderer/internal/TileRendererInternal.java
index b2a8c6c..869a322 100644
--- a/wear/tiles/tiles-renderer/src/main/java/androidx/wear/tiles/renderer/internal/TileRendererInternal.java
+++ b/wear/tiles/tiles-renderer/src/main/java/androidx/wear/tiles/renderer/internal/TileRendererInternal.java
@@ -40,6 +40,7 @@
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils.TruncateAt;
+import android.text.method.LinkMovementMethod;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
@@ -59,6 +60,7 @@
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.LinearLayout;
+import android.widget.Scroller;
import android.widget.Space;
import android.widget.TextView;
@@ -1517,15 +1519,26 @@
SpannableStringBuilder builder = new SpannableStringBuilder();
+ boolean isAnySpanClickable = false;
for (Span element : spannable.getSpansList()) {
switch (element.getInnerCase()) {
case IMAGE:
SpanImage protoImage = element.getImage();
builder = inflateImageInSpannable(builder, protoImage, tv);
+
+ if (protoImage.getModifiers().hasClickable()) {
+ isAnySpanClickable = true;
+ }
+
break;
case TEXT:
SpanText protoText = element.getText();
builder = inflateTextInSpannable(builder, protoText);
+
+ if (protoText.getModifiers().hasClickable()) {
+ isAnySpanClickable = true;
+ }
+
break;
default:
Log.w(TAG, "Unknown Span child type.");
@@ -1568,8 +1581,23 @@
tv.setText(builder);
+ if (isAnySpanClickable) {
+ // For any ClickableSpans to work, the MovementMethod must be set to LinkMovementMethod.
+ tv.setMovementMethod(LinkMovementMethod.getInstance());
+
+ // Disable the highlight color; if we don't do this, the clicked span will get
+ // highlighted, which will be cleared half a second later if using LoadAction as the
+ // next layout will be delivered, which recreates the elements and clears the highlight.
+ tv.setHighlightColor(Color.TRANSPARENT);
+
+ // Use InhibitingScroller to prevent the text from scrolling when tapped. Setting a
+ // MovementMethod on a TextView (e.g. for clickables in a Spannable) then cause the
+ // TextView to be scrollable, and to jump to the end when tapped.
+ tv.setScroller(new InhibitingScroller(mUiContext));
+ }
+
View wrappedView = applyModifiers(tv, spannable.getModifiers());
- parent.addView(applyModifiers(tv, spannable.getModifiers()), layoutParams);
+ parent.addView(wrappedView, layoutParams);
return wrappedView;
}
@@ -1958,4 +1986,17 @@
}
}
}
+
+ /** Implementation of {@link Scroller} which inhibits all scrolling. */
+ private static class InhibitingScroller extends Scroller {
+ InhibitingScroller(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void startScroll(int startX, int startY, int dx, int dy) {}
+
+ @Override
+ public void startScroll(int startX, int startY, int dx, int dy, int duration) {}
+ }
}
diff --git a/wear/tiles/tiles-renderer/src/test/java/androidx/wear/tiles/renderer/internal/TileRendererInternalTest.java b/wear/tiles/tiles-renderer/src/test/java/androidx/wear/tiles/renderer/internal/TileRendererInternalTest.java
index 56d3e02..720732c 100644
--- a/wear/tiles/tiles-renderer/src/test/java/androidx/wear/tiles/renderer/internal/TileRendererInternalTest.java
+++ b/wear/tiles/tiles-renderer/src/test/java/androidx/wear/tiles/renderer/internal/TileRendererInternalTest.java
@@ -29,6 +29,8 @@
import android.content.pm.ActivityInfo;
import android.graphics.Rect;
import android.os.Looper;
+import android.os.SystemClock;
+import android.view.MotionEvent;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.ViewGroup;
@@ -84,6 +86,7 @@
import androidx.wear.tiles.proto.ModifiersProto.Modifiers;
import androidx.wear.tiles.proto.ModifiersProto.Padding;
import androidx.wear.tiles.proto.ModifiersProto.Semantics;
+import androidx.wear.tiles.proto.ModifiersProto.SpanModifiers;
import androidx.wear.tiles.proto.ResourceProto.AndroidImageResourceByResId;
import androidx.wear.tiles.proto.ResourceProto.ImageResource;
import androidx.wear.tiles.proto.ResourceProto.Resources;
@@ -103,6 +106,8 @@
import org.robolectric.shadows.ShadowPackageManager;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
@RunWith(TilesTestRunner.class)
@@ -1137,6 +1142,60 @@
}
@Test
+ public void inflate_spannable_onClickCanFire() {
+ LayoutElement root = LayoutElement.newBuilder()
+ .setSpannable(Spannable.newBuilder()
+ .addSpans(Span.newBuilder()
+ .setText(SpanText.newBuilder()
+ .setText(StringProp.newBuilder()
+ .setValue("Hello World"))
+ .setModifiers(SpanModifiers.newBuilder()
+ .setClickable(Clickable.newBuilder()
+ .setOnClick(Action.newBuilder()
+ .setLoadAction(LoadAction
+ .getDefaultInstance())))))))
+ .build();
+
+ List<Boolean> hasFiredList = new ArrayList<>();
+ FrameLayout rootLayout =
+ inflateProto(
+ root,
+ /* theme= */0,
+ resourceResolvers(),
+ p -> hasFiredList.add(true));
+
+ TextView tv = (TextView) rootLayout.getChildAt(0);
+
+ // Dispatch a click event to the first View; it should trigger the LoadAction...
+ long startTime = SystemClock.uptimeMillis();
+ MotionEvent evt =
+ MotionEvent.obtain(
+ /* downTime= */ startTime,
+ /* eventTime= */ startTime,
+ MotionEvent.ACTION_DOWN,
+ /* x= */ 5f,
+ /* y= */ 5f,
+ /* metaState= */ 0);
+ tv.dispatchTouchEvent(evt);
+ evt.recycle();
+
+ evt =
+ MotionEvent.obtain(
+ /* downTime= */ startTime,
+ /* eventTime= */ startTime + 100,
+ MotionEvent.ACTION_UP,
+ /* x= */ 5f,
+ /* y= */ 5f,
+ /* metaState= */ 0);
+ tv.dispatchTouchEvent(evt);
+ evt.recycle();
+
+ shadowOf(Looper.getMainLooper()).idle();
+
+ assertThat(hasFiredList).hasSize(1);
+ }
+
+ @Test
public void inflate_image_intrinsicSizeIsIgnored() {
LayoutElement root = LayoutElement.newBuilder()
.setBox(Box.newBuilder()