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:
parent
75e7b9f1a5
commit
61720c3472
10 changed files with 397 additions and 15 deletions
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -55,7 +55,7 @@
|
|||
android:textStyle="bold" />
|
||||
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/aboutLicenseInfoTextView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -69,7 +69,7 @@
|
|||
android:textSize="16sp"
|
||||
tools:text="@string/about_tusky_license" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/aboutWebsiteInfoTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -82,7 +82,7 @@
|
|||
android:textSize="16sp"
|
||||
tools:text="@string/about_project_site" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/aboutBugsFeaturesInfoTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -125,4 +125,4 @@
|
|||
|
||||
<include layout="@layout/item_status_bottom_sheet" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
|
@ -216,7 +216,7 @@
|
|||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/accountNoteTextView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
||||
tools:text="13:37" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -157,7 +157,7 @@
|
|||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -103,7 +103,7 @@
|
|||
app:layout_constraintTop_toTopOf="@id/status_display_name"
|
||||
tools:text="13:37" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -144,7 +144,7 @@
|
|||
tools:text="@string/post_content_warning_show_more"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/status_display_name"
|
||||
tools:text="\@ConnyDuck\@mastodon.social" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -116,7 +116,7 @@
|
|||
app:layout_constraintTop_toBottomOf="@+id/status_content_warning_description"
|
||||
tools:text="@string/post_content_warning_show_more" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="\@Tusky edited 18th December 2022" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/status_edit_content_warning_description"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
|
||||
</RelativeLayout>
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/notification_content_warning_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
@ -102,7 +102,7 @@
|
|||
style="@style/TuskyButton.Outlined"
|
||||
android:textSize="?attr/status_text_medium" />
|
||||
|
||||
<TextView
|
||||
<com.keylesspalace.tusky.view.ClickableSpanTextView
|
||||
android:id="@+id/notification_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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">
|
||||
<attr name="name" format="string|reference" />
|
||||
<attr name="license" format="string|reference" />
|
||||
|
@ -29,4 +34,4 @@
|
|||
<attr name="status_text_medium" format="dimension" />
|
||||
<attr name="status_text_large" format="dimension" />
|
||||
|
||||
</resources>
|
||||
</resources>
|
||||
|
|
|
@ -62,5 +62,8 @@
|
|||
|
||||
<dimen name="graph_line_thickness">1dp</dimen>
|
||||
|
||||
<dimen name="minimum_touch_target">48dp</dimen>
|
||||
|
||||
<dimen name="lrPaddedSpanRadius">4dp</dimen>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue