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