• 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.content.Context;
20 import android.graphics.drawable.Drawable;
21 import android.os.Build.VERSION;
22 import android.os.Build.VERSION_CODES;
23 import androidx.core.view.ViewCompat;
24 import androidx.appcompat.widget.AppCompatTextView;
25 import android.text.Annotation;
26 import android.text.SpannableString;
27 import android.text.Spanned;
28 import android.text.method.MovementMethod;
29 import android.text.style.ClickableSpan;
30 import android.text.style.TextAppearanceSpan;
31 import android.text.style.TypefaceSpan;
32 import android.util.AttributeSet;
33 import android.util.Log;
34 import android.view.MotionEvent;
35 import com.google.android.setupdesign.span.LinkSpan;
36 import com.google.android.setupdesign.span.LinkSpan.OnLinkClickListener;
37 import com.google.android.setupdesign.span.SpanHelper;
38 import com.google.android.setupdesign.util.LinkAccessibilityHelper;
39 import com.google.android.setupdesign.view.TouchableMovementMethod.TouchableLinkMovementMethod;
40 
41 /**
42  * An extension of TextView that automatically replaces the annotation tags as specified in {@link
43  * SpanHelper#replaceSpan(android.text.Spannable, Object, Object)}
44  */
45 public class RichTextView extends AppCompatTextView implements OnLinkClickListener {
46 
47   /* static section */
48 
49   private static final String TAG = "RichTextView";
50 
51   private static final String ANNOTATION_LINK = "link";
52   private static final String ANNOTATION_TEXT_APPEARANCE = "textAppearance";
53 
54   /**
55    * Replace <annotation> tags in strings to become their respective types. Currently 2 types
56    * are supported:
57    *
58    * <ol>
59    *   <li>&lt;annotation link="foobar"&gt; will create a {@link
60    *       com.google.android.setupdesign.span.LinkSpan} that broadcasts with the key "foobar"
61    *   <li>&lt;annotation textAppearance="TextAppearance.FooBar"&gt; will create a {@link
62    *       android.text.style.TextAppearanceSpan} with @style/TextAppearance.FooBar
63    * </ol>
64    */
getRichText(Context context, CharSequence text)65   public static CharSequence getRichText(Context context, CharSequence text) {
66     if (text instanceof Spanned) {
67       final SpannableString spannable = new SpannableString(text);
68       final Annotation[] spans = spannable.getSpans(0, spannable.length(), Annotation.class);
69       for (Annotation span : spans) {
70         final String key = span.getKey();
71         if (ANNOTATION_TEXT_APPEARANCE.equals(key)) {
72           String textAppearance = span.getValue();
73           final int style =
74               context
75                   .getResources()
76                   .getIdentifier(textAppearance, "style", context.getPackageName());
77           if (style == 0) {
78             Log.w(TAG, "Cannot find resource: " + style);
79           }
80           final TextAppearanceSpan textAppearanceSpan = new TextAppearanceSpan(context, style);
81           SpanHelper.replaceSpan(spannable, span, textAppearanceSpan);
82         } else if (ANNOTATION_LINK.equals(key)) {
83           LinkSpan link = new LinkSpan(span.getValue());
84           TypefaceSpan typefaceSpan = new TypefaceSpan("sans-serif-medium");
85           SpanHelper.replaceSpan(spannable, span, link, typefaceSpan);
86         }
87       }
88       return spannable;
89     }
90     return text;
91   }
92 
93   /* non-static section */
94 
95   private LinkAccessibilityHelper accessibilityHelper;
96   private OnLinkClickListener onLinkClickListener;
97 
RichTextView(Context context)98   public RichTextView(Context context) {
99     super(context);
100     init();
101   }
102 
RichTextView(Context context, AttributeSet attrs)103   public RichTextView(Context context, AttributeSet attrs) {
104     super(context, attrs);
105     init();
106   }
107 
init()108   private void init() {
109     accessibilityHelper = new LinkAccessibilityHelper(this);
110     ViewCompat.setAccessibilityDelegate(this, accessibilityHelper);
111   }
112 
113   @Override
setText(CharSequence text, BufferType type)114   public void setText(CharSequence text, BufferType type) {
115     text = getRichText(getContext(), text);
116     // Set text first before doing anything else because setMovementMethod internally calls
117     // setText. This in turn ends up calling this method with mText as the first parameter
118     super.setText(text, type);
119     boolean hasLinks = hasLinks(text);
120 
121     if (hasLinks) {
122       // When a TextView has a movement method, it will set the view to clickable. This makes
123       // View.onTouchEvent always return true and consumes the touch event, essentially
124       // nullifying any return values of MovementMethod.onTouchEvent.
125       // To still allow propagating touch events to the parent when this view doesn't have
126       // links, we only set the movement method here if the text contains links.
127       setMovementMethod(TouchableLinkMovementMethod.getInstance());
128     } else {
129       setMovementMethod(null);
130     }
131     // ExploreByTouchHelper automatically enables focus for RichTextView
132     // even though it may not have any links. Causes problems during talkback
133     // as individual TextViews consume touch events and thereby reducing the focus window
134     // shown by Talkback. Disable focus if there are no links
135     setFocusable(hasLinks);
136     // Do not "reveal" (i.e. scroll to) this view when this view is focused. Since this view is
137     // focusable in touch mode, we may be focused when the screen is first shown, and starting
138     // a screen halfway scrolled down is confusing to the user.
139     if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) {
140       setRevealOnFocusHint(false);
141       // setRevealOnFocusHint is a new API added in SDK 25. For lower SDK versions, do not
142       // call setFocusableInTouchMode. We won't get touch effect on those earlier versions,
143       // but the link will still work, and will prevent the scroll view from starting halfway
144       // down the page.
145       setFocusableInTouchMode(hasLinks);
146     }
147   }
148 
hasLinks(CharSequence text)149   private boolean hasLinks(CharSequence text) {
150     if (text instanceof Spanned) {
151       final ClickableSpan[] spans =
152           ((Spanned) text).getSpans(0, text.length(), ClickableSpan.class);
153       return spans.length > 0;
154     }
155     return false;
156   }
157 
158   @Override
159   @SuppressWarnings("ClickableViewAccessibility") // super.onTouchEvent is called
onTouchEvent(MotionEvent event)160   public boolean onTouchEvent(MotionEvent event) {
161     // Since View#onTouchEvent always return true if the view is clickable (which is the case
162     // when a TextView has a movement method), override the implementation to allow the movement
163     // method, if it implements TouchableMovementMethod, to say that the touch is not handled,
164     // allowing the event to bubble up to the parent view.
165     boolean superResult = super.onTouchEvent(event);
166     MovementMethod movementMethod = getMovementMethod();
167     if (movementMethod instanceof TouchableMovementMethod) {
168       TouchableMovementMethod touchableMovementMethod = (TouchableMovementMethod) movementMethod;
169       if (touchableMovementMethod.getLastTouchEvent() == event) {
170         return touchableMovementMethod.isLastTouchEventHandled();
171       }
172     }
173     return superResult;
174   }
175 
176   @Override
dispatchHoverEvent(MotionEvent event)177   protected boolean dispatchHoverEvent(MotionEvent event) {
178     if (accessibilityHelper != null && accessibilityHelper.dispatchHoverEvent(event)) {
179       return true;
180     }
181     return super.dispatchHoverEvent(event);
182   }
183 
184   @Override
drawableStateChanged()185   protected void drawableStateChanged() {
186     super.drawableStateChanged();
187 
188     if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
189       // b/26765507 causes drawableStart and drawableEnd to not get the right state on M. As a
190       // workaround, set the state on those drawables directly.
191       final int[] state = getDrawableState();
192       for (Drawable drawable : getCompoundDrawablesRelative()) {
193         if (drawable != null) {
194           if (drawable.setState(state)) {
195             invalidateDrawable(drawable);
196           }
197         }
198       }
199     }
200   }
201 
setOnLinkClickListener(OnLinkClickListener listener)202   public void setOnLinkClickListener(OnLinkClickListener listener) {
203     onLinkClickListener = listener;
204   }
205 
getOnLinkClickListener()206   public OnLinkClickListener getOnLinkClickListener() {
207     return onLinkClickListener;
208   }
209 
210   @Override
onLinkClick(LinkSpan span)211   public boolean onLinkClick(LinkSpan span) {
212     if (onLinkClickListener != null) {
213       return onLinkClickListener.onLinkClick(span);
214     }
215     return false;
216   }
217 }
218