Meet accessibility guidelines for clickable spans (#3382)

Clickable spans in textviews do not normally meet the Android accessibility
guidelines of a minimum 48dp square touch target.

This can't be fixed with a `TouchDelegate`, as the span is not a separate
view to which the delegate can be attached.

Add `ClickableSpanTextView`. If used instead of a `TextView`, any spans
from `ClickableSpan` will have their touchable area extended to meet the
48dp minimum.

The touchable area is still bounded by the size of the view.

If two spans are closer together than 48dp then the closest span to the
touch wins.

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
This commit is contained in:
Nik Clayton 2023-03-18 08:57:39 +01:00 committed by GitHub
parent 75e7b9f1a5
commit 61720c3472
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 397 additions and 15 deletions

View file

@ -0,0 +1,374 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.view
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.text.Spanned
import android.text.style.ClickableSpan
import android.text.style.URLSpan
import android.util.AttributeSet
import android.util.Log
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_UP
import android.view.ViewConfiguration
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.doOnLayout
import com.keylesspalace.tusky.BuildConfig
import com.keylesspalace.tusky.R
import java.lang.Float.max
import java.lang.Float.min
import kotlin.math.abs
/**
* Displays text to the user with optional [ClickableSpan]s. Extends the touchable area of the spans
* to ensure they meet the minimum size of 48dp x 48dp for accessibility requirements.
*
* If the touchable area of multiple spans overlap the touch is dispatched to the closest span.
*/
class ClickableSpanTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = android.R.attr.textViewStyle
) : AppCompatTextView(context, attrs, defStyleAttr) {
/**
* Map of [RectF] that enclose the [ClickableSpan] without any additional touchable area. A span
* may extend over more than one line, so multiple entries in this map may point to the same
* span.
*/
private val spanRects = mutableMapOf<RectF, ClickableSpan>()
/**
* Map of [RectF] that enclose the [ClickableSpan] with the additional touchable area. A span
* may extend over more than one line, so multiple entries in this map may point to the same
* span.
*/
private val delegateRects = mutableMapOf<RectF, ClickableSpan>()
/**
* The [ClickableSpan] that is used for the point the user has touched. Null if the user is
* not tapping, or the point they have touched is not associated with a span.
*/
private var clickedSpan: ClickableSpan? = null
/** The minimum size, in pixels, of a touchable area for accessibility purposes */
private val minDimenPx = resources.getDimensionPixelSize(R.dimen.minimum_touch_target)
/**
* Debugging helper. Normally false, set this to true to show a border around spans, and
* shade their touchable area.
*/
private val showSpanBoundaries = false
/**
* Debugging helper. The paint to use to draw a span.
*/
private lateinit var spanDebugPaint: Paint
/**
* Debugging helper. The paint to use to shade a span's touchable area.
*/
private lateinit var paddingDebugPaint: Paint
init {
// Initialise debugging paints, if appropriate. Only ever present in debug builds, and
// is optimised out if showSpanBoundaries is false.
if (BuildConfig.DEBUG && showSpanBoundaries) {
spanDebugPaint = Paint()
spanDebugPaint.color = Color.BLACK
spanDebugPaint.style = Paint.Style.STROKE
paddingDebugPaint = Paint()
paddingDebugPaint.color = Color.MAGENTA
paddingDebugPaint.alpha = 50
}
}
override fun onTextChanged(
text: CharSequence?,
start: Int,
lengthBefore: Int,
lengthAfter: Int
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
doOnLayout { measureSpans() }
}
/**
* Compute [Rect]s for each [ClickableSpan].
*
* Each span is associated with at least two Rects. One for the span itself, and one for the
* touchable area around the span.
*
* If the span runs over multiple lines there will be two Rects per line.
*/
private fun measureSpans() {
val spannedText = text as? Spanned ?: return
spanRects.clear()
delegateRects.clear()
// The goal is to record all the [Rect]s associated with a span with the same fidelity
// that the user sees when they highlight text in the view to select it.
//
// There's no method in [TextView] or [Layout] that does exactly that. [Layout.getSelection]
// would be perfect, but it's not accessible. However, [Layout.getSelectionPath] is. That
// records the Rects between two characters in the string, and handles text that spans
// multiple lines, is bidirectional, etc.
//
// However, it records them in to a [Path], and a Path has no mechanism to extract the
// Rects saved in to it.
//
// So subclass Path with [RectRecordingPath], which records the data from calls to
// [addRect]. Pass that to `getSelectionPath` to extract all the Rects between start and
// end.
val rects = mutableListOf<RectF>()
val rectRecorder = RectRecordingPath(rects)
for (span in spannedText.getSpans(0, text.length - 1, ClickableSpan::class.java)) {
rects.clear()
val spanStart = spannedText.getSpanStart(span)
val spanEnd = spannedText.getSpanEnd(span)
// Collect all the Rects for this span
layout.getSelectionPath(spanStart, spanEnd, rectRecorder)
// Save them
for (rect in rects) {
// Adjust to account for the view's padding and gravity
rect.offset(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
rect.bottom += extendedPaddingBottom
// The rect wraps just the span, with no additional touchable area. Save a copy.
spanRects[RectF(rect)] = span
// Adjust the rect to meet the minimum dimensions
if (rect.height() < minDimenPx) {
val yOffset = (minDimenPx - rect.height()) / 2
rect.top = max(0f, rect.top - yOffset)
rect.bottom = min(rect.bottom + yOffset, bottom.toFloat())
}
if (rect.width() < minDimenPx) {
val xOffset = (minDimenPx - rect.width()) / 2
rect.left = max(0f, rect.left - xOffset)
rect.right = min(rect.right + xOffset, right.toFloat())
}
// Save it
delegateRects[rect] = span
}
}
}
/**
* Handle some touch events.
*
* - [ACTION_DOWN]: Determine which, if any span, has been clicked, and save in clickedSpan
* - [ACTION_UP]: If a span was saved then dispatch the click to that span
* - [ACTION_CANCEL]: Clear the saved span
*
* Defer to the parent class for other touches.
*/
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
event ?: return super.onTouchEvent(null)
if (delegateRects.isEmpty()) return super.onTouchEvent(event)
when (event.action) {
ACTION_DOWN -> {
clickedSpan = null
val x = event.x
val y = event.y
// If the user has clicked directly on a span then use it, ignoring any overlap
for (entry in spanRects) {
if (!entry.key.contains(x, y)) continue
clickedSpan = entry.value
Log.v(TAG, "span click: ${(clickedSpan as URLSpan).url}")
return super.onTouchEvent(event)
}
// Otherwise, check to see if it's in a touchable area
var activeEntry: MutableMap.MutableEntry<RectF, ClickableSpan>? = null
for (entry in delegateRects) {
if (entry == activeEntry) continue
if (!entry.key.contains(x, y)) continue
if (activeEntry == null) {
activeEntry = entry
continue
}
Log.v(TAG, "Overlap: ${(entry.value as URLSpan).url} ${(activeEntry.value as URLSpan).url}")
if (isClickOnFirst(entry.key, activeEntry.key, x, y)) {
activeEntry = entry
}
}
clickedSpan = activeEntry?.value
clickedSpan?.let { Log.v(TAG, "padding click: ${(clickedSpan as URLSpan).url}") }
return super.onTouchEvent(event)
}
ACTION_UP -> {
clickedSpan?.let {
clickedSpan = null
val duration = event.eventTime - event.downTime
if (duration <= ViewConfiguration.getLongPressTimeout()) {
it.onClick(this)
return true
}
}
return super.onTouchEvent(event)
}
ACTION_CANCEL -> {
clickedSpan = null
return super.onTouchEvent(event)
}
else -> return super.onTouchEvent(event)
}
}
/**
* Determine whether a click on overlapping rectangles should be attributed to the first or the
* second rectangle.
*
* When the user clicks on the overlap it has to be attributed to the "best" rectangle. The
* rectangles have equivalent z-order, so their "closeness" to the user in the Z-plane is not
* a consideration.
*
* The chosen rectangle depends on whether they overlap top/bottom (the top of one rect is
* not the same as the top of the other rect), or they overlap left/right (the tops of both
* rects are the same).
*
* In this example the rectangles overlap top/bottom because their top edges are not aligned.
*
* ```
* +--------------+
* |1 |
* | +--------------+
* | |2 |
* | | |
* | | |
* +------| |
* | |
* +--------------+
* ```
*
* (Rectangle #1 being partially occluded by rectangle #2 is for clarity in the diagram, it
* does not affect the algorithm)
*
* Take the Y coordinate of the centre of each rectangle.
*
* ```
* +--------------+
* |1 |
* | +--------------+
* |......|2 | <-- Rect #1 centre line
* | | |
* | |..............| <-- Rect #2 centre line
* +------| |
* | |
* +--------------+
* ```
*
* Take the Y position of the click, and determine which Y centre coordinate it is closest too.
* Whichever one is closest is the clicked rectangle.
*
* In these examples the left column of numbers is the Y coordinate, `*` marks the point where
* the user clicked.
*
* ```
* 0 +--------------+ +--------------+
* 1 |1 | |1 |
* 2 | +--------------+ | +--------------+
* 3 |......|2 * | |......|2 |
* 4 | | | | | |
* 5 | |..............| | |*.............|
* 6 +------| | +------| |
* 7 | | | |
* 8 +--------------+ +--------------+
*
* Rect #1 centre Y = 3
* Rect #2 centre Y = 5
* Click (*) Y = 3 Click (*) Y = 5
* Result: Rect #1 is clicked Result: Rect #2 is clicked
* ```
*
* The approach is the same if the rectangles overlap left/right, but the X coordinate of the
* centre of the rectangle is tested against the X coordinate of the click.
*
* @param first rectangle to test against
* @param second rectangle to test against
* @param x coordinate of user click
* @param y coordinate of user click
* @return true if the click was closer to the first rectangle than the second
*/
private fun isClickOnFirst(first: RectF, second: RectF, x: Float, y: Float): Boolean {
Log.v(TAG, "first: $first second: $second click: $x $y")
val (firstDiff, secondDiff) = if (first.top == second.top) {
Log.v(TAG, "left/right overlap")
Pair(abs(first.centerX() - x), abs(second.centerX() - x))
} else {
Log.v(TAG, "top/bottom overlap")
Pair(abs(first.centerY() - y), abs(second.centerY() - y))
}
Log.d(TAG, "firstDiff: $firstDiff secondDiff: $secondDiff")
return firstDiff < secondDiff
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// Paint span boundaries. Optimised out on release builds, or debug builds where
// showSpanBoundaries is false.
if (BuildConfig.DEBUG && showSpanBoundaries) {
canvas?.save()
for (entry in delegateRects) {
canvas?.drawRect(entry.key, paddingDebugPaint)
}
for (entry in spanRects) {
canvas?.drawRect(entry.key, spanDebugPaint)
}
canvas?.restore()
}
}
companion object {
const val TAG = "LinkTextView"
}
}
/**
* A [Path] that records the contents of all the [addRect] calls it receives.
*
* @param rects list to record the received [RectF]
*/
private class RectRecordingPath(private val rects: MutableList<RectF>) : Path() {
override fun addRect(left: Float, top: Float, right: Float, bottom: Float, dir: Direction) {
rects.add(RectF(left, top, right, bottom))
}
}

View file

@ -55,7 +55,7 @@
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/aboutLicenseInfoTextView" android:id="@+id/aboutLicenseInfoTextView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -69,7 +69,7 @@
android:textSize="16sp" android:textSize="16sp"
tools:text="@string/about_tusky_license" /> tools:text="@string/about_tusky_license" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/aboutWebsiteInfoTextView" android:id="@+id/aboutWebsiteInfoTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -82,7 +82,7 @@
android:textSize="16sp" android:textSize="16sp"
tools:text="@string/about_project_site" /> tools:text="@string/about_project_site" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/aboutBugsFeaturesInfoTextView" android:id="@+id/aboutBugsFeaturesInfoTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -216,7 +216,7 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" /> app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/accountNoteTextView" android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -122,7 +122,7 @@
app:layout_constraintTop_toTopOf="@id/status_display_name" app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" /> tools:text="13:37" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content_warning_description" android:id="@+id/status_content_warning_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -157,7 +157,7 @@
tools:text="@string/post_content_warning_show_more" tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content" android:id="@+id/status_content"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -103,7 +103,7 @@
app:layout_constraintTop_toTopOf="@id/status_display_name" app:layout_constraintTop_toTopOf="@id/status_display_name"
tools:text="13:37" /> tools:text="13:37" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content_warning_description" android:id="@+id/status_content_warning_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -144,7 +144,7 @@
tools:text="@string/post_content_warning_show_more" tools:text="@string/post_content_warning_show_more"
tools:visibility="visible" /> tools:visibility="visible" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content" android:id="@+id/status_content"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -78,7 +78,7 @@
app:layout_constraintTop_toBottomOf="@id/status_display_name" app:layout_constraintTop_toBottomOf="@id/status_display_name"
tools:text="\@ConnyDuck\@mastodon.social" /> tools:text="\@ConnyDuck\@mastodon.social" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content_warning_description" android:id="@+id/status_content_warning_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -116,7 +116,7 @@
app:layout_constraintTop_toBottomOf="@+id/status_content_warning_description" app:layout_constraintTop_toBottomOf="@+id/status_content_warning_description"
tools:text="@string/post_content_warning_show_more" /> tools:text="@string/post_content_warning_show_more" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_content" android:id="@+id/status_content"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -36,7 +36,7 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="\@Tusky edited 18th December 2022" /> tools:text="\@Tusky edited 18th December 2022" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/status_edit_content_warning_description" android:id="@+id/status_edit_content_warning_description"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -71,7 +71,7 @@
</RelativeLayout> </RelativeLayout>
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/notification_content_warning_description" android:id="@+id/notification_content_warning_description"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@ -102,7 +102,7 @@
style="@style/TuskyButton.Outlined" style="@style/TuskyButton.Outlined"
android:textSize="?attr/status_text_medium" /> android:textSize="?attr/status_text_medium" />
<TextView <com.keylesspalace.tusky.view.ClickableSpanTextView
android:id="@+id/notification_content" android:id="@+id/notification_content"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View file

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<declare-styleable name="ClickableSpanTextView">
<!-- Necessary to support tools:text in Android Studio layout designer -->
<attr name="android:text"/>
</declare-styleable>
<declare-styleable name="LicenseCard"> <declare-styleable name="LicenseCard">
<attr name="name" format="string|reference" /> <attr name="name" format="string|reference" />
<attr name="license" format="string|reference" /> <attr name="license" format="string|reference" />

View file

@ -62,5 +62,8 @@
<dimen name="graph_line_thickness">1dp</dimen> <dimen name="graph_line_thickness">1dp</dimen>
<dimen name="minimum_touch_target">48dp</dimen>
<dimen name="lrPaddedSpanRadius">4dp</dimen> <dimen name="lrPaddedSpanRadius">4dp</dimen>
</resources> </resources>