Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Is there any way to focus spannable text for accessibility in Android?

I have a String with multiple Links. I am using SpannableText and It is working perfect except accessibility talk back.

Is there any way to give accessibility to Links?

like image 557
Deepu George Jacob Avatar asked Jan 11 '17 16:01

Deepu George Jacob


1 Answers

Create a class like below

package com.go.disney.disneycruise.util

import android.content.Context
import android.graphics.Rect
import android.os.Bundle
import android.text.Layout
import android.text.Spanned
import android.text.style.ClickableSpan
import android.view.accessibility.AccessibilityEvent
import android.widget.TextView
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.customview.widget.ExploreByTouchHelper
import com.disney.wdpro.dlog.DLog
import com.disney.wdpro.support.accessibility.ContentDescriptionBuilder
import kotlin.math.max
import kotlin.math.min

/**
 * An accessibility delegate that allows [ClickableSpan] to be focused and
 * clicked by accessibility services.
 *
 * @author arun.muraleedharan.
 */
class LinkAccessibilityHelper(
    private val linkTextView: TextView,
    private val context: Context,
    private val linkAccessibilityId: String? = null
) : ExploreByTouchHelper(linkTextView) {

    private val viewRect = Rect()

    override fun getVirtualViewAt(x: Float, y: Float): Int {
        val text = linkTextView.text
        if (text is Spanned) {
            val offset = getOffsetForPosition(linkTextView, x, y)
            val linkSpans = text.getSpans(offset, offset, ClickableSpan::class.java)
            if (linkSpans.size == 1) {
                val linkSpan = linkSpans[0]
                return text.getSpanStart(linkSpan)
            }
        }
        return INVALID_ID
    }

    override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
        val text = linkTextView.text
        if (text is Spanned) {
            val linkSpans = text.getSpans(0, text.length, ClickableSpan::class.java)
            for (span in linkSpans) {
                virtualViewIds.add(text.getSpanStart(span))
            }
        }
    }

    override fun onPopulateEventForVirtualView(virtualViewId: Int, event: AccessibilityEvent) {
        val span = getSpanForOffset(virtualViewId)
        if (span != null) {
            val spannedTextAccessibility = getTextForSpan(span).toString() + LINK
            event.contentDescription = spannedTextAccessibility
        } else {
            DLog.e(TAG, "LinkSpan is null for offset: $virtualViewId")
            event.contentDescription = linkTextView.text
        }
    }

    override fun onPopulateNodeForVirtualView(
        virtualViewId: Int,
        info: AccessibilityNodeInfoCompat
    ) {
        linkAccessibilityId?.let { info.viewIdResourceName = it }
        val span = getSpanForOffset(virtualViewId)
        if (span != null) {
            val contentDescriptionBuilder = ContentDescriptionBuilder(
                context
            )
            contentDescriptionBuilder.append(getTextForSpan(span).toString())
            contentDescriptionBuilder.appendWithSeparator(LINK)
            info.contentDescription = contentDescriptionBuilder.toString()
        } else {
            DLog.e(TAG, "LinkSpan is null for offset: $virtualViewId")
            info.contentDescription = linkTextView.text
        }
        info.isFocusable = true
        info.isClickable = true
        getBoundsForSpan(span, viewRect)
        if (viewRect.isEmpty) {
            DLog.e(TAG, "LinkSpan bounds is empty for: $virtualViewId")
            viewRect[0, 0, 1] = 1
        }
        info.setBoundsInParent(viewRect)
        info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK)
    }

    override fun onPerformActionForVirtualView(
        virtualViewId: Int,
        action: Int,
        arguments: Bundle?
    ): Boolean {
        if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
            val span = getSpanForOffset(virtualViewId)
            if (span != null) {
                span.onClick(linkTextView)
                return true
            } else {
                DLog.e(TAG, "LinkSpan is null for offset: $virtualViewId")
            }
        }
        return false
    }

    private fun getSpanForOffset(offset: Int): ClickableSpan? {
        val text = linkTextView.text
        if (text is Spanned) {
            val spans = text.getSpans(offset, offset, ClickableSpan::class.java)
            if (spans.size == 1) {
                return spans[0]
            }
        }
        return null
    }

    private fun getTextForSpan(span: ClickableSpan): CharSequence {
        val text = linkTextView.text
        if (text is Spanned) {
            return text.subSequence(
                text.getSpanStart(span),
                text.getSpanEnd(span)
            )
        }
        return text
    }

    // Find the bounds of a span. If it spans multiple lines, it will only return the bounds for the
    // section on the first line.
    private fun getBoundsForSpan(span: ClickableSpan?, outRect: Rect): Rect {
        val text = linkTextView.text
        outRect.setEmpty()
        if (text is Spanned) {
            val layout = linkTextView.layout
            if (layout != null) {
                val spanStart = text.getSpanStart(span)
                val spanEnd = text.getSpanEnd(span)
                val xStart = layout.getPrimaryHorizontal(spanStart)
                val xEnd = layout.getPrimaryHorizontal(spanEnd)
                val lineStart = layout.getLineForOffset(spanStart)
                val lineEnd = layout.getLineForOffset(spanEnd)
                layout.getLineBounds(lineStart, outRect)
                if (lineEnd == lineStart) {
                    // If the span is on a single line, adjust both the left and right bounds
                    // so outrect is exactly bounding the span.
                    outRect.left = min(xStart, xEnd).toInt()
                    outRect.right = max(xStart, xEnd).toInt()
                } else {
                    // If the span wraps across multiple lines, only use the first line (as returned
                    // by layout.getLineBounds above), and adjust the "start" of outrect to where
                    // the span starts, leaving the "end" of outrect at the end of the line.
                    // ("start" being left for LTR, and right for RTL)
                    if (layout.getParagraphDirection(lineStart) == Layout.DIR_RIGHT_TO_LEFT) {
                        outRect.right = xStart.toInt()
                    } else {
                        outRect.left = xStart.toInt()
                    }
                }

                // Offset for padding
                outRect.offset(linkTextView.totalPaddingLeft, linkTextView.totalPaddingTop)
            }
        }
        return outRect
    }

    companion object {
        private const val TAG = "LinkAccessibilityHelper"
        const val LINK = "link."

        // Compat implementation of TextView#getOffsetForPosition().
        private fun getOffsetForPosition(view: TextView, x: Float, y: Float): Int {
            if (view.layout == null) {
                return -1
            }
            val line = getLineAtCoordinate(view, y)
            return getOffsetAtCoordinate(view, line, x)
        }

        private fun convertToLocalHorizontalCoordinate(view: TextView, x: Float): Float {
            var coordinateX = x
            coordinateX -= view.totalPaddingLeft.toFloat()
            // Clamp the position to inside of the view.
            coordinateX = max(0.0f, coordinateX)
            coordinateX = min((view.width - view.totalPaddingRight - 1).toFloat(), coordinateX)
            coordinateX += view.scrollX.toFloat()
            return coordinateX
        }

        private fun getLineAtCoordinate(view: TextView, y: Float): Int {
            var coordinateY = y
            coordinateY -= view.totalPaddingTop.toFloat()
            // Clamp the position to inside of the view.
            coordinateY = max(0.0f, coordinateY)
            coordinateY = min((view.height - view.totalPaddingBottom - 1).toFloat(), coordinateY)
            coordinateY += view.scrollY.toFloat()
            return view.layout.getLineForVertical(coordinateY.toInt())
        }

        private fun getOffsetAtCoordinate(view: TextView, line: Int, x: Float): Int {
            var coordinateX = x
            coordinateX = convertToLocalHorizontalCoordinate(view, coordinateX)
            return view.layout.getOffsetForHorizontal(line, coordinateX)
        }
    }
}

After that implement it like below

ViewCompat.setAccessibilityDelegate(
                textView, LinkAccessibilityHelper(
                    textView, context, "linkId"
                )
            )
like image 160
Raghu Krishnan R Avatar answered Nov 19 '22 07:11

Raghu Krishnan R