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><annotation link="foobar"> will create a {@link 67 * com.google.android.setupdesign.span.BoldLinkSpan} that broadcasts with the key "foobar" 68 * <li><annotation textAppearance="TextAppearance.FooBar"> 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