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