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.android.setupwizardlib.util; 18 19 import android.graphics.Rect; 20 import android.os.Bundle; 21 import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; 22 import android.support.v4.widget.ExploreByTouchHelper; 23 import android.text.Layout; 24 import android.text.Spanned; 25 import android.text.style.ClickableSpan; 26 import android.util.Log; 27 import android.view.accessibility.AccessibilityEvent; 28 import android.widget.TextView; 29 30 import java.util.List; 31 32 /** 33 * An accessibility delegate that allows {@link android.text.style.ClickableSpan} to be focused and 34 * clicked by accessibility services. 35 * 36 * <p />Sample usage: 37 * <pre> 38 * LinkAccessibilityHelper mAccessibilityHelper; 39 * 40 * private void init() { 41 * mAccessibilityHelper = new LinkAccessibilityHelper(myTextView); 42 * ViewCompat.setAccessibilityDelegate(myTextView, mLinkHelper); 43 * } 44 * 45 * {@literal @}Override 46 * protected boolean dispatchHoverEvent({@literal @}NonNull MotionEvent event) { 47 * if (mAccessibilityHelper != null && mAccessibilityHelper.dispatchHoverEvent(event)) { 48 * return true; 49 * } 50 * return super.dispatchHoverEvent(event); 51 * } 52 * </pre> 53 * 54 * @see com.android.setupwizardlib.view.RichTextView 55 * @see android.support.v4.widget.ExploreByTouchHelper 56 */ 57 public class LinkAccessibilityHelper extends ExploreByTouchHelper { 58 59 private static final String TAG = "LinkAccessibilityHelper"; 60 61 private final TextView mView; 62 private final Rect mTempRect = new Rect(); 63 64 public LinkAccessibilityHelper(TextView view) { 65 super(view); 66 mView = view; 67 } 68 69 @Override 70 protected int getVirtualViewAt(float x, float y) { 71 final CharSequence text = mView.getText(); 72 if (text instanceof Spanned) { 73 final Spanned spannedText = (Spanned) text; 74 final int offset = getOffsetForPosition(mView, x, y); 75 ClickableSpan[] linkSpans = spannedText.getSpans(offset, offset, ClickableSpan.class); 76 if (linkSpans.length == 1) { 77 ClickableSpan linkSpan = linkSpans[0]; 78 return spannedText.getSpanStart(linkSpan); 79 } 80 } 81 return INVALID_ID; 82 } 83 84 @Override 85 protected void getVisibleVirtualViews(List<Integer> virtualViewIds) { 86 final CharSequence text = mView.getText(); 87 if (text instanceof Spanned) { 88 final Spanned spannedText = (Spanned) text; 89 ClickableSpan[] linkSpans = spannedText.getSpans(0, spannedText.length(), 90 ClickableSpan.class); 91 for (ClickableSpan span : linkSpans) { 92 virtualViewIds.add(spannedText.getSpanStart(span)); 93 } 94 } 95 } 96 97 @Override 98 protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { 99 final ClickableSpan span = getSpanForOffset(virtualViewId); 100 if (span != null) { 101 event.setContentDescription(getTextForSpan(span)); 102 } else { 103 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 104 event.setContentDescription(mView.getText()); 105 } 106 } 107 108 @Override 109 protected void onPopulateNodeForVirtualView(int virtualViewId, 110 AccessibilityNodeInfoCompat info) { 111 final ClickableSpan span = getSpanForOffset(virtualViewId); 112 if (span != null) { 113 info.setContentDescription(getTextForSpan(span)); 114 } else { 115 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 116 info.setContentDescription(mView.getText()); 117 } 118 info.setFocusable(true); 119 info.setClickable(true); 120 getBoundsForSpan(span, mTempRect); 121 if (mTempRect.isEmpty()) { 122 Log.e(TAG, "LinkSpan bounds is empty for: " + virtualViewId); 123 mTempRect.set(0, 0, 1, 1); 124 } 125 info.setBoundsInParent(mTempRect); 126 info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); 127 } 128 129 @Override 130 protected boolean onPerformActionForVirtualView(int virtualViewId, int action, 131 Bundle arguments) { 132 if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) { 133 ClickableSpan span = getSpanForOffset(virtualViewId); 134 if (span != null) { 135 span.onClick(mView); 136 return true; 137 } else { 138 Log.e(TAG, "LinkSpan is null for offset: " + virtualViewId); 139 } 140 } 141 return false; 142 } 143 144 private ClickableSpan getSpanForOffset(int offset) { 145 CharSequence text = mView.getText(); 146 if (text instanceof Spanned) { 147 Spanned spannedText = (Spanned) text; 148 ClickableSpan[] spans = spannedText.getSpans(offset, offset, ClickableSpan.class); 149 if (spans.length == 1) { 150 return spans[0]; 151 } 152 } 153 return null; 154 } 155 156 private CharSequence getTextForSpan(ClickableSpan span) { 157 CharSequence text = mView.getText(); 158 if (text instanceof Spanned) { 159 Spanned spannedText = (Spanned) text; 160 return spannedText.subSequence(spannedText.getSpanStart(span), 161 spannedText.getSpanEnd(span)); 162 } 163 return text; 164 } 165 166 // Find the bounds of a span. If it spans multiple lines, it will only return the bounds for the 167 // section on the first line. 168 private Rect getBoundsForSpan(ClickableSpan span, Rect outRect) { 169 CharSequence text = mView.getText(); 170 outRect.setEmpty(); 171 if (text instanceof Spanned) { 172 final Layout layout = mView.getLayout(); 173 if (layout != null) { 174 Spanned spannedText = (Spanned) text; 175 final int spanStart = spannedText.getSpanStart(span); 176 final int spanEnd = spannedText.getSpanEnd(span); 177 final float xStart = layout.getPrimaryHorizontal(spanStart); 178 final float xEnd = layout.getPrimaryHorizontal(spanEnd); 179 final int lineStart = layout.getLineForOffset(spanStart); 180 final int lineEnd = layout.getLineForOffset(spanEnd); 181 layout.getLineBounds(lineStart, outRect); 182 if (lineEnd == lineStart) { 183 // If the span is on a single line, adjust both the left and right bounds 184 // so outrect is exactly bounding the span. 185 outRect.left = (int) Math.min(xStart, xEnd); 186 outRect.right = (int) Math.max(xStart, xEnd); 187 } else { 188 // If the span wraps across multiple lines, only use the first line (as returned 189 // by layout.getLineBounds above), and adjust the "start" of outrect to where 190 // the span starts, leaving the "end" of outrect at the end of the line. 191 // ("start" being left for LTR, and right for RTL) 192 if (layout.getParagraphDirection(lineStart) == Layout.DIR_RIGHT_TO_LEFT) { 193 outRect.right = (int) xStart; 194 } else { 195 outRect.left = (int) xStart; 196 } 197 } 198 199 // Offset for padding 200 outRect.offset(mView.getTotalPaddingLeft(), mView.getTotalPaddingTop()); 201 } 202 } 203 return outRect; 204 } 205 206 // Compat implementation of TextView#getOffsetForPosition(). 207 208 private static int getOffsetForPosition(TextView view, float x, float y) { 209 if (view.getLayout() == null) return -1; 210 final int line = getLineAtCoordinate(view, y); 211 return getOffsetAtCoordinate(view, line, x); 212 } 213 214 private static float convertToLocalHorizontalCoordinate(TextView view, float x) { 215 x -= view.getTotalPaddingLeft(); 216 // Clamp the position to inside of the view. 217 x = Math.max(0.0f, x); 218 x = Math.min(view.getWidth() - view.getTotalPaddingRight() - 1, x); 219 x += view.getScrollX(); 220 return x; 221 } 222 223 private static int getLineAtCoordinate(TextView view, float y) { 224 y -= view.getTotalPaddingTop(); 225 // Clamp the position to inside of the view. 226 y = Math.max(0.0f, y); 227 y = Math.min(view.getHeight() - view.getTotalPaddingBottom() - 1, y); 228 y += view.getScrollY(); 229 return view.getLayout().getLineForVertical((int) y); 230 } 231 232 private static int getOffsetAtCoordinate(TextView view, int line, float x) { 233 x = convertToLocalHorizontalCoordinate(view, x); 234 return view.getLayout().getOffsetForHorizontal(line, x); 235 } 236 } 237