1

How to tell talkback NOT to include header to a count, when reading recycler view using talkback?

I have a RecyclerView with two types of Views: Header and Item When talkback is on, and header is selected it says:

"Header1 in list of 3 items" - including header, header is not really an item, it's just a header/title for the section, so it shouldn't be counted for.

My setup is pretty straightforward:

package com.example.myapplication

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val list = listOf<Item>(
            Item("Header1", true),
            Item("ItemA", false),
            Item("ItemB", false),
            Item("ItemC", false),
            )

        val adapter = SharedDataAdapter(this, list)
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = LinearLayoutManager(applicationContext)
        recyclerView.adapter = adapter
    }
}

data class Item(val text: String, val isHeader: Boolean)

class SharedDataAdapter(
    private val context: Context, private val dataList: List<Item>
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val VIEW_TYPE_HEADER = 0
    private val VIEW_TYPE_ITEM = 1

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val inflater = LayoutInflater.from(context)
        return if(viewType == VIEW_TYPE_HEADER){
            val headerView = inflater.inflate(R.layout.header_layout, parent, false)
            HeaderViewHolder(headerView)
        } else {
            val itemView = inflater.inflate(R.layout.list_item_layout, parent, false)
            ItemViewHolder(itemView)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if(getItemViewType(position) == VIEW_TYPE_HEADER){
            (holder as HeaderViewHolder).bindHeader(dataList[position])
        } else {
            (holder as ItemViewHolder).bindItem(dataList[position])
        }
    }

    override fun getItemCount(): Int = dataList.size

    override fun getItemViewType(position: Int): Int {
        return if(dataList[position].isHeader) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
    }

    private class  HeaderViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
        private val headerTextView: TextView = itemView.findViewById(R.id.textView)


        fun bindHeader(headerText: Item){
            headerTextView.text = headerText.text
        }
    }

    private class ItemViewHolder(itemView: View): RecyclerView.ViewHolder(itemView){
        val itemTextView: TextView = itemView.findViewById(R.id.textView)

        fun bindItem(itemText: Item){
            itemTextView.text = itemText.text
        }
    }
}

ActivityMain.xml:

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

header_layout.xml

<TextView android:id="@+id/textView"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    tools:text="Header"
    android:accessibilityHeading="true"
 />

list_item_layout.xml

<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="\u2022"
        android:importantForAccessibility="no"
        tools:ignore="HardcodedText" />

    <TextView android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        tools:text="Item"
        />
</androidx.appcompat.widget.LinearLayoutCompat>
Remik
  • 25
  • 1
  • 2
  • 11

2 Answers2

1

How to tell talkback NOT to include header to a count, when reading recycler view using talkback?

Customizing how a feature (here the TalkBack one) interacts with and describes your RecyclerView items means using a delegate.
In this delegate, you can modify the accessibility information of headers, so they are not counted as part of the list by TalkBack.

See as an example of Delete adapter the article "Delegate Adapters: Building Heterogeneous RecyclerView Adapter" from Sultan Seidalin, and its associated repository Sultan1993/EasyDelegateAdapter.

In your case, you would first create an AccessibilityDelegate for your RecyclerView:

val customAccessibilityDelegate = object : View.AccessibilityDelegate() {
    override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        
        // Check if the current view is a header
        val position = host.tag as? Int
        if (position != null && dataList[position].isHeader) {
            // Remove it from being considered part of the list
            info.collectionItemInfo = null
        }
    }
}

Then, in your onBindViewHolder method of the SharedDataAdapter, set this accessibility delegate for each view and also set a tag, so you know the position of the item (this helps you determine if it is a header or not):

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    holder.itemView.accessibilityDelegate = customAccessibilityDelegate
    holder.itemView.tag = position

    if(getItemViewType(position) == VIEW_TYPE_HEADER){
        (holder as HeaderViewHolder).bindHeader(dataList[position])
    } else {
        (holder as ItemViewHolder).bindItem(dataList[position])
    }
}

That would set an AccessibilityDelegate to each item of the RecyclerView. In the delegate, you check if the current item (or view) is a header. If it is, its collectionItemInfo is set to null, essentially telling TalkBack that this view should not be considered a part of the list.


The header is now announced correctly, but still ItemA is announced as "Item 2 of 4" and should be "Item 1 of 3" because Header shouldn't count to the items list.

Let's keep the header focusable: TalkBack needs to be able to focus on the header. Just do not include the header in the item count. When each item is focused, you want to adjust its item position and the total item count.

In your RecyclerView.Adapter, in onBindViewHolder, set an AccessibilityDelegate for each view:

init {
    view.accessibilityDelegate = object : View.AccessibilityDelegate() {
        @RequiresApi(Build.VERSION_CODES.R)
        override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfo) {
            super.onInitializeAccessibilityNodeInfo(host, info)

            if (isHeader(adapterPosition)) {
                val collectionItemInfo = AccessibilityNodeInfo.CollectionItemInfo(
                    0, 1, 0, 1, true
                )
                info.collectionItemInfo = collectionItemInfo
                info.text = "Header1"  // Explicitly set the text for the header
            } else {
                // Adjust position and count to exclude headers
                var adjustedPosition = adapterPosition
                var adjustedCount = itemCount - 1  // Exclude header for total count
                for (i in 0 until adapterPosition) {
                    if (isHeader(i)) adjustedPosition--
                }
                val collectionItemInfo = AccessibilityNodeInfo.CollectionItemInfo(
                    adjustedPosition, 1, 0, 1, false
                )
                info.collectionItemInfo = collectionItemInfo
                info.text = "Item ${adjustedPosition + 1} of $adjustedCount"
            }
        }
    }
}

private fun isHeader(position: Int): Boolean {
    return getItemViewType(position) == VIEW_TYPE_HEADER
}

isHeader(position) is used to check if the current position corresponds to the header.

  • For the header, CollectionItemInfo is called, where the header is marked as a heading (true in the fifth parameter). The last parameter sets the selection status, which is set to 0 (indicating it's not selectable like list items). Note: Instead of obtain(), I directly construct the CollectionItemInfo, since the obtain() method was deprecated in API level 33, and object pooling has been discontinued.
  • For items, the position is adjusted by - 1 since the header is not counted, and then the content description is set with the adjusted position and count.

Make sure that your isHeader method correctly checks for the header position. The code assumes that the header is at position 0.


After discussion, the OP went for using Jetpack Compose, a fully declarative UI toolkit for building native Android applications.

That could mean switching from using a RecyclerView to using a LazyColumn, which is Compose's equivalent for vertical lists.

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
  • Almost, the header is now announced correctly, but still ItemA is announced as "Item 2 of 4" and should be "Item 1 of 3" because Header shouldn't count to the items list. – Remik Aug 17 '23 at 10:55
  • @Remik OK. I have edited the answer to address your comment. – VonC Aug 17 '23 at 11:06
  • I've tried your suggestion, didn't work: 1) if I set importantForAccessibility to false, talkback won't announce the header at all. I want it to announce it as it is now. So without the count 2) When I set onInitializeAccessibilityEvent() even with hardcoded value i.e 3, it just break the talkback, selecting the whole screen so I'm able to select any of Items for a talkback. – Remik Aug 17 '23 at 11:24
  • @Remik OK. I have rewritten the last part of this answer to suggests an alternative approach. – VonC Aug 17 '23 at 11:32
  • now header is read like "(Item ... position of ...) heading in list 4 items". (should be 3, excluding header) And items are read like: Item 2 of 4 item 3 of 4 item 4 of 4 – Remik Aug 17 '23 at 11:54
  • @Remik Right, I suppose I need to prevent the system from treating the header as an item in a list by overriding the collection-related attributes. I will edit the code shortly. – VonC Aug 17 '23 at 11:57
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/254941/discussion-between-vonc-and-remik). – VonC Aug 17 '23 at 12:06
  • I've ended up just using Jetpack compose. But your answer was as close to the solution that I've got :). – Remik Aug 21 '23 at 06:13
  • @Remik OK, that would work too! Did you use a `LazyColumn`? – VonC Aug 21 '23 at 06:21
1

The problem is due to you including the header name in the adapter containing the recycler view, since your header name is within the list of items, your count will not return the perfect position. Use ConcatAdapter, this will help you keep the adapter and list items separate. Check this link for an example on how to implement it.

You can also check this answer click here

Hope it helps.