• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2016 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.google.android.setupdesign.view;
18 
19 import android.annotation.TargetApi;
20 import android.content.Context;
21 import android.graphics.Typeface;
22 import android.graphics.drawable.Drawable;
23 import android.os.Build.VERSION;
24 import android.os.Build.VERSION_CODES;
25 import androidx.appcompat.widget.AppCompatTextView;
26 import android.text.Annotation;
27 import android.text.SpannableString;
28 import android.text.Spanned;
29 import android.text.method.MovementMethod;
30 import android.text.style.ClickableSpan;
31 import android.text.style.TextAppearanceSpan;
32 import android.text.style.TypefaceSpan;
33 import android.util.AttributeSet;
34 import android.util.Log;
35 import android.view.MotionEvent;
36 import androidx.annotation.VisibleForTesting;
37 import androidx.core.view.ViewCompat;
38 import com.google.android.setupcompat.util.BuildCompatUtils;
39 import com.google.android.setupdesign.accessibility.LinkAccessibilityHelper;
40 import com.google.android.setupdesign.span.BoldLinkSpan;
41 import com.google.android.setupdesign.span.LinkSpan;
42 import com.google.android.setupdesign.span.LinkSpan.OnLinkClickListener;
43 import com.google.android.setupdesign.span.SpanHelper;
44 import com.google.android.setupdesign.view.TouchableMovementMethod.TouchableLinkMovementMethod;
45 
46 /**
47  * An extension of TextView that automatically replaces the annotation tags as specified in {@link
48  * SpanHelper#replaceSpan(android.text.Spannable, Object, Object)}
49  */
50 public class RichTextView extends AppCompatTextView implements OnLinkClickListener {
51 
52   /* static section */
53 
54   private static final String TAG = "RichTextView";
55 
56   private static final String ANNOTATION_LINK = "link";
57   private static final String ANNOTATION_TEXT_APPEARANCE = "textAppearance";
58 
59   @VisibleForTesting static Typeface spanTypeface;
60 
61   /**
62    * Replace <annotation> tags in strings to become their respective types. Currently 2 types
63    * are supported:
64    *
65    * <ol>
66    *   <li>&lt;annotation link="foobar"&gt; will create a {@link
67    *       com.google.android.setupdesign.span.BoldLinkSpan} that broadcasts with the key "foobar"
68    *   <li>&lt;annotation textAppearance="TextAppearance.FooBar"&gt; will create a {@link
69    *       android.text.style.TextAppearanceSpan} with @style/TextAppearance.FooBar
70    * </ol>
71    */
72   @TargetApi(28)
getRichText(Context context, CharSequence text)73   public static CharSequence getRichText(Context context, CharSequence text) {
74     if (text instanceof Spanned) {
75       final SpannableString spannable = new SpannableString(text);
76       final Annotation[] spans = spannable.getSpans(0, spannable.length(), Annotation.class);
77       for (Annotation span : spans) {
78         final String key = span.getKey();
79         if (ANNOTATION_TEXT_APPEARANCE.equals(key)) {
80           String textAppearance = span.getValue();
81           final int style =
82               context
83                   .getResources()
84                   .getIdentifier(textAppearance, "style", context.getPackageName());
85           if (style == 0) {
86             Log.w(TAG, "Cannot find resource: " + style);
87           }
88           final TextAppearanceSpan textAppearanceSpan = new TextAppearanceSpan(context, style);
89           SpanHelper.replaceSpan(spannable, span, textAppearanceSpan);
90         } else if (ANNOTATION_LINK.equals(key)) {
91           LinkSpan link;
92           if (BuildCompatUtils.isAtLeastU()) {
93             link = new BoldLinkSpan(context, span.getValue());
94           } else {
95             link = new LinkSpan(span.getValue());
96           }
97           TypefaceSpan typefaceSpan =
98               (spanTypeface != null)
99                   ? new TypefaceSpan(spanTypeface)
100                   : new TypefaceSpan("sans-serif-medium");
101           SpanHelper.replaceSpan(spannable, span, link, typefaceSpan);
102         }
103       }
104       return spannable;
105     }
106     return text;
107   }
108 
109   /* non-static section */
110 
111   private LinkAccessibilityHelper accessibilityHelper;
112   private OnLinkClickListener onLinkClickListener;
113 
RichTextView(Context context)114   public RichTextView(Context context) {
115     super(context);
116     init();
117   }
118 
RichTextView(Context context, AttributeSet attrs)119   public RichTextView(Context context, AttributeSet attrs) {
120     super(context, attrs);
121     init();
122   }
123 
init()124   private void init() {
125     if (isInEditMode()) {
126       return;
127     }
128 
129     accessibilityHelper = new LinkAccessibilityHelper(this);
130     ViewCompat.setAccessibilityDelegate(this, accessibilityHelper);
131   }
132 
133   /**
134    * Sets the typeface in which the text should be displayed. The default typeface is {@code
135    * "sans-serif-medium"}
136    *
137    * @throws java.lang.NoSuchMethodError if sdk lower than {@code VERSION_CODES.P}
138    */
139   @TargetApi(VERSION_CODES.P)
setSpanTypeface(Typeface typeface)140   public void setSpanTypeface(Typeface typeface) {
141     spanTypeface = typeface;
142   }
143 
144   @Override
setText(CharSequence text, BufferType type)145   public void setText(CharSequence text, BufferType type) {
146     text = getRichText(getContext(), text);
147     // Set text first before doing anything else because setMovementMethod internally calls
148     // setText. This in turn ends up calling this method with mText as the first parameter
149     super.setText(text, type);
150     boolean hasLinks = hasLinks(text);
151 
152     if (hasLinks) {
153       // When a TextView has a movement method, it will set the view to clickable. This makes
154       // View.onTouchEvent always return true and consumes the touch event, essentially
155       // nullifying any return values of MovementMethod.onTouchEvent.
156       // To still allow propagating touch events to the parent when this view doesn't have
157       // links, we only set the movement method here if the text contains links.
158       setMovementMethod(TouchableLinkMovementMethod.getInstance());
159     } else {
160       setMovementMethod(null);
161     }
162     // ExploreByTouchHelper automatically enables focus for RichTextView
163     // even though it may not have any links. Causes problems during talkback
164     // as individual TextViews consume touch events and thereby reducing the focus window
165     // shown by Talkback. Disable focus if there are no links
166     setFocusable(hasLinks);
167     // Do not "reveal" (i.e. scroll to) this view when this view is focused. Since this view is
168     // focusable in touch mode, we may be focused when the screen is first shown, and starting
169     // a screen halfway scrolled down is confusing to the user.
170     if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) {
171       setRevealOnFocusHint(false);
172       // setRevealOnFocusHint is a new API added in SDK 25. For lower SDK versions, do not
173       // call setFocusableInTouchMode. We won't get touch effect on those earlier versions,
174       // but the link will still work, and will prevent the scroll view from starting halfway
175       // down the page.
176       setFocusableInTouchMode(hasLinks);
177     }
178   }
179 
hasLinks(CharSequence text)180   private boolean hasLinks(CharSequence text) {
181     if (text instanceof Spanned) {
182       final ClickableSpan[] spans =
183           ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
184       return spans.length > 0;
185     }
186     return false;
187   }
188 
189   @Override
190   @SuppressWarnings("ClickableViewAccessibility") // super.onTouchEvent is called
onTouchEvent(MotionEvent event)191   public boolean onTouchEvent(MotionEvent event) {
192     // Since View#onTouchEvent always return true if the view is clickable (which is the case
193     // when a TextView has a movement method), override the implementation to allow the movement
194     // method, if it implements TouchableMovementMethod, to say that the touch is not handled,
195     // allowing the event to bubble up to the parent view.
196     boolean superResult = super.onTouchEvent(event);
197     MovementMethod movementMethod = getMovementMethod();
198     if (movementMethod instanceof TouchableMovementMethod) {
199       TouchableMovementMethod touchableMovementMethod = (TouchableMovementMethod) movementMethod;
200       if (touchableMovementMethod.getLastTouchEvent() == event) {
201         return touchableMovementMethod.isLastTouchEventHandled();
202       }
203     }
204     return superResult;
205   }
206 
207   @Override
dispatchHoverEvent(MotionEvent event)208   protected boolean dispatchHoverEvent(MotionEvent event) {
209     if (accessibilityHelper != null && accessibilityHelper.dispatchHoverEvent(event)) {
210       return true;
211     }
212     return super.dispatchHoverEvent(event);
213   }
214 
215   @Override
drawableStateChanged()216   protected void drawableStateChanged() {
217     super.drawableStateChanged();
218 
219     if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
220       // b/26765507 causes drawableStart and drawableEnd to not get the right state on M. As a
221       // workaround, set the state on those drawables directly.
222       final int[] state = getDrawableState();
223       for (Drawable drawable : getCompoundDrawablesRelative()) {
224         if (drawable != null) {
225           if (drawable.setState(state)) {
226             invalidateDrawable(drawable);
227           }
228         }
229       }
230     }
231   }
232 
setOnLinkClickListener(OnLinkClickListener listener)233   public void setOnLinkClickListener(OnLinkClickListener listener) {
234     onLinkClickListener = listener;
235   }
236 
getOnLinkClickListener()237   public OnLinkClickListener getOnLinkClickListener() {
238     return onLinkClickListener;
239   }
240 
241   @Override
onLinkClick(LinkSpan span)242   public boolean onLinkClick(LinkSpan span) {
243     if (onLinkClickListener != null) {
244       return onLinkClickListener.onLinkClick(span);
245     }
246     return false;
247   }
248 }
249