Android accessibility开发笔记

写在前面,本人在适配accessibility的时候,并没有去理解accessibility的原理,所以很多解决方案都是“试”出来的。

概述

先说一下accessibility是做什么的。accessibility很多手机会翻译成无障碍或者辅助功能等,既然是无障碍/辅助功能,那肯定是一个可以帮助用户更好的使用手机的一个功能。不过我这里是针对屏幕阅读器这个功能去开发。
所谓屏幕阅读器,就是当用户点击屏幕的某一处的时候,会将屏幕上的文本阅读出来。如果是图片,并且图片有设置contentDescription这个属性,则会将contentDescription里面的内容阅读出来。这也为什么我们在开发的时候,android studio为什么一直提示我们设置这个属性的原因。就是希望开发者可以对accessibility进行适配,让盲人用户也可以使用我们的app。

准备

  • 真机
  • 模拟器。如果是模拟器,需要安装talkback,否则没办法测试。

talkback链接:https://pan.baidu.com/s/1ffZcilRuTJc3OB24lvcmYA 提取码:nv97

条件允许的话,建议用真机测试,因为用模拟器测试操作起来会很别扭,具体看个人习惯吧。如果是真机测试,建议在测试之前,把手机底部的导航键设置为显示。因为在开启accessibility的情况下,用手势返回桌面等操作是非常难的。
然后有一个问题,不清楚是不是因为我的工作电脑被装了一堆监控软件才会这样,还是说模拟器本身就会这样。就是在测试accessibilty的时候,偶尔会出现不管点哪里模拟器都没有反应。这个时候就需要关闭模拟器,然后在android studio点击模拟器的cool boot now,重新启动模拟器就可以了。
如果有三星手机并且有spen,也可以使用spen测试accessibility。亲身体验,用spen测试accessibility真的不错,用起来就像没有平常操作一样。如果用手操作,滑动是需要使用双指才可以滑动的,但用了spen,就可以像以前那样滑动。点击也是一样,用手指需要双击,用了spen就可以单击。

设置
找到talkback,如果是手机,就找一下无障碍或者屏幕阅读等关键字。这个时候会看到类似这样一个界面。

  • 点击setting
  • 找到Developer settring,模拟器的话,是在最下面,手机就不一定了
  • 进入到Developer setting之后,点击Display speech output,这个功能的作用是:当点击某个或某段文本的时候,会用toast的方式将文本显示出来。这样在测试的时候,就不用慢慢听手机将这些文本读出来之后才知道有没有问题。个人建议开启这个
  • 返回到talkback界面,点击use service,然后点击下面的这段文本,就会发现有toast

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

开发

其实上面也提到了,会将屏幕的文本读取出来,所以即使我们没有写一行任何和accessibility有关的代码,我们的app的大部分功能或界面也已经对accessibility进行了支持。
但是,光屏幕阅读是不够的,不然也不至于需要单独拿出来讲,也不至于项目老大要求我们做这个功能。
在做这个功能的过程中,给我的感触最深的就是:要把accessibility做好,需要的是站在盲人的角度看问题,否则就很难看到accessibility需要做的事情。就比如说,一个button,在正常人眼中,一看就知道这是一个可以点击的界面。但对于盲人来说,他看不到,他不知道这是一个button。所以我们需要做的是,让他们知道这是一个button,一般的做法就是:在读出button的文本之后,在后面追加button。

标题的顺序是想到什么就写什么,所以可以根据自己的需要跳到相应的位置。目前就记得在工作中遇到下面这些问题,如果还想起来什么问题,会补充。

localization

在提及有哪些需要适配之前,先把本地化提一下吧,如果app有要求做本地化的功能,那accessibility的本地化也跑不掉,所以统一讲了。
就拿上面的button的来讲,如果有这样一个button。

<TextView
    android:id="@+id/tv"
    android:layout_width="wrap_content"
    android:text="@string/text"
    android:layout_height="wrap_content"/>

这个button是一个TextView,要让accesssibility读的时候在后面追加button有很几种方式,我就拿contentDescription来讲,因为始终离不开localization这个问题,所以用什么方式不是重点。做法是:将contentDescription设置为text, button。这个时候问题就来了,localization要怎么做?可以这样:

/ /string.xml
<string name="content_description_format">%s,%s</string>
<string name="text">text</string>
<string name="button">button</string>

// MainActivity
tv.contentDescription = getString(R.string.content_description_format,getString(R.string.text),getString(R.string.button))

然后在不同的string.xml文件中对button这个text做不同的翻译。
当然了,实际上,还有其他设置button的方式,这里只是简单举例,后面会提供其他方式供选择,这里的重点是本地化。
在这里插入图片描述
回到开发

设置后缀的方式

设置后缀有几种方式我并不知道,我就提四种种目前我所知道的方式吧。
第一种:
就是上面提到的contentDescription,上面也贴出了相应的代码,这里就不再赘述。
第二种:
利用view的tooltip

// 在自定义View中,override getTooltip
// 这里需要注意,只能在这里返回tooltip,不要调用setTooltipText
// 如果调用了,即使不打开屏幕阅读器,长按该View也会将该View的tooltip显示到屏幕上
override fun getTooltipText(): CharSequence? {
    return "button"
}

ViewCompat.setAccessibilityDelegate(tv, object : AccessibilityDelegateCompat(){
    override fun onInitializeAccessibilityNodeInfo(host: View?, info: AccessibilityNodeInfoCompat?) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info?.tooltipText = "button"
    }
})

第三种:
设置roleDescription,可以理解为给一个View设置一个role,然后在读的时候就会有后缀。

ViewCompat.setAccessibilityDelegate(tv, object : AccessibilityDelegateCompat() {
    override fun onInitializeAccessibilityNodeInfo(
        host: View,
        info: AccessibilityNodeInfoCompat
    ) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        // 设置一个role,然后就会将button读出来
        info.roleDescription = getString(R.string.button)
    }
})

第四种:
设置View的class name或者在accessibilityDelegate设置className

// 重写view的accessibilityClassName
@Override
public CharSequence getAccessibilityClassName() {
    return ImageButton.class.getName();
}
// 使用accessibilityDelegate的方式
ViewCompat.setAccessibilityDelegate(tv,object :AccessibilityDelegateCompat(){
    override fun onInitializeAccessibilityNodeInfo(host: View?, info: AccessibilityNodeInfoCompat?) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info?.className = Button::class.java.name
    }
})

这里有一点必须提醒一下,只有部分View可以这样做。目前已知:

  • ImageButton/Button -> Button
  • EditText -> Edit Box
  • RadioButton -> Radio Button
  • CheckBox -> Check Box

目前只知道这些,因为我工作中只遇到这些需要做accessibility。看起来好像很少的样子,但其实已经可以覆盖大部分场景了。使用这些class name之后,就不需要提供翻译了,accessibility会根据系统语言将不同的翻译读出来。如果想要知道是否还有其他的class&nsbp;name,可以自己设置不同的class上去,看看能不能看到没见过的role。
然后有一点想提醒一下,一般复杂的view设置完class name之后,必须手动设置一下contentDescription,否则role可能就不是后缀了,而变成了前缀。

再补一下为什么会知道可以这样做。
在修改accessibility的bug的时候,我发现使用ImageButton和Button就可以让图片和位置自带button属性,然后就看了一下ImagaButton/ImageView和Button/TextView之间有什么不同,最后发现了ImageButton的getAccessibilityClassName方法返回ImageButton.class.getName();,Button也是类似的代码,这里就不贴出来了。
所以我就尝试自定义一个View,然后重写这个方法,写一个Button class name上去,最后经过测试,发现是可行的。
不过我还是没办法理解为什么这样做是可行的,在View里面没有找到有什么有用的代码。再加上上面提到,会根据系统语言将role翻译成不同的语言,所以我估计要看安卓系统的源码才能找到答案,有哪几种class name也就不知道了。

回到开发

AccessibilityDelegate

上面提到了accessibility delegate,这里就顺便拿出来讲一下,因为后面很多地方都会提到。
单击该方法看源码的时候就会发现,最终会将我们设置的deletegate设置给view的mAccessibilityDelegate变量,而view内部在调用和accessibility相关的方法时,如onInitializeAccessibilityNodeInfo,会先判断delegate是否为空,如果不为空就调用delegate的代码。而如果为空,就调用view自己的Internal方法。

public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    if (mAccessibilityDelegate != null) {
        mAccessibilityDelegate.onInitializeAccessibilityNodeInfo(this, info);
    } else {
        onInitializeAccessibilityNodeInfoInternal(info);
    }
}

下面我或多或少会用到delegate相关的代码,而如果你开发的view是一个自定义view,则完全可以重载相关方法,而不需要使用delegate这种方式。我举的例子使用delegate只是为了方便,因为实际开发中,自定义的view并没有那么多。

回到开发

button

如果某个View是一个button,那必须保证在点击的时候,将button读出来。而在日常开发中,我们的app肯定有大量button,我们知道它们是button,但可能不会意识到,这些button都需要我们手动适配。
先将一个最简单的适配也是用的比较少的button的方式抛出来。只要我们在开发中,使用Button这个控件,就不用手动适配button,并且还会顺便解决localization的问题。这个就不贴图了,自己去尝试一下就知道了。
但大家都是做开发的,也非常清楚,日常开发中很少情况下可以使用Button这个控件,所以手动适配才是常规操作。
所以我就将一些常见的button列举出来,然后可以将下面这几种button去做适配。
app的返回键:大部分情况下是一张图片,也可以使用ImageButton,这样就不用做适配。
复杂的button:假设一个button里面有好几个控件,这种控件用button显然也是不可能的,所以就只能手动适配。假设有这样一个控件。

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <RelativeLayout
        android:id="@+id/test_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/name_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:layout_marginTop="20dp"
            android:text="name"
            android:textSize="15sp" />

        <TextView
            android:id="@+id/price_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_marginTop="20dp"
            android:layout_marginEnd="20dp"
            android:text="price" />

        <TextView
            android:id="@+id/extra_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_below="@+id/name_tv"
            android:layout_marginStart="20dp"
            android:layout_marginTop="20dp"
            android:layout_marginBottom="20dp"
            android:text="long text" />
    </RelativeLayout>
</FrameLayout>

// 注意:一定要设置setOnClickListener,否则这个不能单独点击
test_button.setOnClickListener {
}

在这里插入图片描述
会发现,toast的内容是这3个控件的内容。所以如果想让它变成一个button,那就需要手动设置contentDescription。

不过在看复杂代码之前,我先提供一个比较简单的方法,像这里的ViewGroup是RelativeLayout,就可以自定义一个如AccessibilityRelativeLayout,然后重写getTooltip方法或者onInitializeAccessibilityNodeInfoInternal,返回一个button的tooltip,这样就不需要用下面这种比较麻烦的方法。当然了,如果是ConstraintLayout,就自定义一个ConstraintLayout,代码我不就不提供了。
下面的代码是另一种解决方式

// 可以传多个TextView,也可以传多个CharSequence
object AccessibilityHelper {
    fun setDescriptionForSomeTextView(targetView: View, suffixText: String, vararg textViews: TextView) {
        setDescriptionForSomeText(targetView, suffixText, *textViews.filter { it.text?.isNotEmpty() ?: false }.let { textViewList ->
                Array(textViewList.size) { index ->
                    textViewList[index].text
                }
            })
    }

    fun setDescriptionForSomeText(targetView: View, suffixText: String, vararg texts: CharSequence) {
        val divider = ", "
        targetView.contentDescription = targetView.context.getString(
            R.string.content_description_format,
            texts.joinToString(divider),
            suffixText
        )
    }
}

// 用法
AccessibilityHelper.setDescriptionForSomeTextView(test_button, getString(R.string.button), name_tv, price_tv, extra_tv)

当然了,上面这段代码也有作用。如果需求是name, long text, price,那可能就需要使用上面这种方式,修改一下text的顺序,或者尝试使用AccessibilityTraversal,这个就接着看吧,下面会提。

RecyclerView的item:RecyclerView的item也会有button那个问题,这个的话,可以给itemView设置contentDescription,可以使用我上面提供的那个方法。
然后还有个问题,在第一次进入RecyclerView的时候,会提示n of m, in list。如果离开RecyclerView则会提示out list。这里的n就是position,m是itemCount。意思就是告诉用户这是第n个项。如果想去掉后面这个,可以给RecyclerView设置一个属性。注意:这个属性是View提供的,不是RecyclerView特有的。

// 下面两种方案任选一种即可
// 在xml里面
android:importantForAccessibility="no"

// 在java或kotlin里面
recycler.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
recycler.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);

下面也会解释这个important是干嘛的

回到开发

Heading

先说一下这个功能是干嘛的,就是如果某个TextView是一个标题,可以设置这个TextView为一个Headding,这样当用户点到屏幕上的这个TextView的时候,就知道这是一个标题。
三种实现方式
方式一:如果app支持的最低版本是28,直接设置Heading为true

android:accessibilityHeading="true"

if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P){
    tv.isAccessibilityHeading = true
}

方式二:如果小于28,用nodeInfo

ViewCompat.setAccessibilityDelegate(head_tv,object :AccessibilityDelegateCompat(){
    override fun onInitializeAccessibilityNodeInfo(
        host: View?,
        info: AccessibilityNodeInfoCompat
    ) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        info.isHeading = true
    }
})

方式三:用contentDescription

// 还是那句话,如果需要做localization的话,那就需要这样子
<string name="heading">heading</string>

head_tv.contentDescription = getString(R.string.content_description_format,getString(R.string.text),getString(R.string.heading))

回到开发

点击事件

在开启accessibilty的时候,由于单击事件变成了将焦点聚焦到某个View,所以想要让一个View触发点击事件的话,就只能双击,这个在点击的时候也一直有提醒,但担心有人不知道,所以这里提一下。

回到开发

滑动事件

在app中一定有 很多界面需要滑动,但开启accessibility之后,单指就没办法正常的上下左右滑动了,这个时候如果想要滑动,就需要使用双指。如果使用模拟器,对于windows系统,需要按住ctrl+鼠标左键。对于mac系统,需要按住command+鼠标左键。
就是因为用模拟器滑动操作起来很别扭,所以我才推荐用真机测试,╮(╯﹏╰)╭。
然后提醒一下,无论是windows系统,还是mac系统,按下键盘上的4个方向键,都可以模拟手机上单指滑动的效果。

回到开发

短信倒计时

如果项目中,存在发送短信的功能,那必须在倒计时的时候,将剩余时间读(announce)出来,否则用户根本不知道要等多久才能重新点击发送短信。
在提供代码之前,有必要提醒一下,说是说必须告诉用户剩余多长时间,但也不是每秒都读出来。因为实际测试的时候,每秒都读出来是真的烦人。之前在开发的时候,拿到的要求是:60、30、15、10、5。在这几个时间点都提醒,其他时候都不要提醒。具体项目具体分析,我这里只是提供一个例子。如果leader要求每秒都要,那就听leader的吧。
关于怎么在特定时间读出来,我的做法是将这几个时间点存到一个list,然后当秒数变化的时候,就判断这个list是否存在这个秒数,如果存在,就读出来。
关于怎么让系统主动读出文本出来,可以看下面的代码。

// View自带的announceForAccessibility方法
announceForAccessibility("text")

object AccessibilityHelper {
    // 或者自己写一个工具方法
    fun announceAccessibilityText(context: Context, text: String) {
        val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
        if (am.isTouchExplorationEnabled) {
            am.interrupt()
            val event = AccessibilityEvent.obtain(AccessibilityEventCompat.TYPE_ANNOUNCEMENT)
            event.text.add(text)
            event.packageName = ""
            event.className = ""
            event.contentDescription = ""
            am.sendAccessibilityEvent(event)
        }
    }
}

回到开发

importantForAccessibility

上面的RecyclerView的item提到这个属性,我就谈一谈对这个属性的理解。由于我是通过不断地试错,才对这个属性有一定的理解。所以如果哪里说得有问题,请指出来,谢谢。

  • 默认(auto):如果不设置的话,默认值就是auto。如果是auto,就直接交给系统去判断和处理。比如RecyclerView,默认就是focus时会通知in list,RecyclerView失去焦点会通知out of list
  • 设置为no时:当accessibility在focus时不会focus到该view。如果view本身是一个view group则会将focus转移到他的子view
  • 设置为yes时:经测试这个比较复杂,还不是很清楚具体规则,而且注释写得也比较简单(The view is important for accessibility),所以只能举例说明

默认值:看了View的源码,有两个方法会自动将这个值设置为yes。setContentDescription和setStateDescription。只要传入的字符串参数不为空,并且importantForAccessibility为auot,就会自动设置为yes。
设置为yes的使用场景
RecyclerView:如果想focus到RecyclerView,它的全部子view的importantForAccessibility都必须设置为no。包括itemView,否则整个RecyclerView都没办法获取到焦点。不过说实话,想尽办法让RecyclerView获取到焦点而不让item获取焦点这种做法一点意义都没有,因为这样做了之后,RecyclerView所有的item都点不了,所以我认为没必要去尝试这样做能否成功。
而上面提到的,可以设置no去除n of m这句话,是因为给RecyclerView设置no之后,accessibility就不会将焦点给RecyclerView,而是给到itemView,所以就不会和RecyclerView产生关联。

所以在RecyclerView本身除了设置为no,没有太多使用场景,但除此之外还有一个比较常见的场景。假设有一个这样的view或itemView

<LinearLayout
    android:layout_width="match_parent"
    android:orientation="horizontal"
    android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:text="text1"
        android:layout_height="wrap_content"/>
    <TextView
        android:layout_width="wrap_content"
        android:text="text2"
        android:layout_height="wrap_content"/>
</LinearLayout>

默认情况下,accessibility可以分别点到这两个View,但需求是只能点到LinearLayout,并将text1和text2连起来,即:text1text2。
此时就可以用yes来解决

 <LinearLayout
    android:layout_width="match_parent"
    android:importantForAccessibility="yes"
    android:focusableInTouchMode="true"
    android:orientation="horizontal"
    android:layout_height="wrap_content">
    <TextView
        android:layout_width="wrap_content"
        android:text="text1"
        android:importantForAccessibility="no"
        android:layout_height="wrap_content"/>
    <TextView
        android:layout_width="wrap_content"
        android:text="text2"
        android:importantForAccessibility="no"
        android:layout_height="wrap_content"/>
</LinearLayout>

先给text1和text2设置为no,让accessibility没办法focus到这两个View,最后给LinearLayout设置为yes,并设置focusableInTouchMode为true,这样accessibility就会focus到该Layout。此时就会将text1和text2的内容读出来。

回到开发

setStateDescription

本来想直接将注释复制过来,但有点长,所以我简单解释一下这个方法的作用。
该方法可以用来更新一个view的状态信息。contentDescription是用来设置不是经常变动的信息,而该方法则是用来设置经常变动的信息。
比如有一个view有seleted和unselected这两种状态,并且该view一个固定contentDescription,比如usd。那该view就有两种contentDescription,分别是usd selected和usd unselected。 那可以给该view的contentDescription设置为usd,而stateDescription设置selected或unselected。
在使用之前有必要提醒一下,该方法只能在大于或等于30的设备上使用。
google官方倒是提供了ViewCompat.setStateDescription(),但经测试,在29的设备上没有生效。
我也测试了在ViewCompat的delegate的nodeInfo设置,也没有生效。

回到开发

loading

在app中,有时下拉刷新,或者点了某个button触发loading。此时可以让系统读出loading,让用户知道现在在loading。
而且也不止这种场景,比如某个dialog或者bottom sheet弹出来或者进入某个Activity/Fragment,觉得有必要的话,也可以让系统读出来。

回到开发

焦点

我所在开发的项目使用的是单Activity+多Fragment的方式,这个时候就存在一个问题。在打开一个新的Fragment之后,leader要求将焦点聚焦到左上角,这种情况下就需要单独处理,否则焦点就会停留在上次点击的位置。解决方式是:调用View的requestFocus()方法。
可以看到,一种是进入界面之后焦点依然停留在点击的地方,一种是焦点停留在左上角,当时我接到的需求就是停留在左上角,所以需要编写代码解决这个问题。
在这里插入图片描述
在这里插入图片描述
本来打算贴小部分代码。想了想,算了,全部贴出来吧,这样才不会看得一头雾水。虽然大部分代码都没什么意义。
AccessibilityActivity.kt

class AccessibilityActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_accessibility)
        supportFragmentManager.beginTransaction().add(R.id.frame_layout,FirstFragment()).commit()
    }
}

activity_accessibility.xml

// 这里的abc_vector_test好像是android sdk自带的一张图片
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#f000">

        <ImageView
            android:id="@+id/back"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:layout_marginTop="20dp"
            android:contentDescription="back"
            android:layout_marginBottom="20dp"
            android:src="@drawable/abc_vector_test" />
    </FrameLayout>
    <FrameLayout
        android:id="@+id/frame_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>
</LinearLayout>

FirstFragment.kt

class FirstFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_first, container, false)
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        recycler.layoutManager = LinearLayoutManager(activity!!, LinearLayoutManager.VERTICAL, false)
        recycler.adapter = MyAdapter{
            (activity as AccessibilityActivity).also {
                it.supportFragmentManager.beginTransaction().add(R.id.frame_layout,SecondFragment()).commit()
            }
        }
    }

    private class MyAdapter(private val onItemClick: () -> Unit) :
        RecyclerView.Adapter<MyAdapter.ViewHolder>() {
        private val datas = (0 until 100).map { "content:$it" }

        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
            return ViewHolder(FrameLayout(parent.context).also {
                it.layoutParams = RecyclerView.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
                )
            })
        }

        override fun onBindViewHolder(holder: ViewHolder, position: Int) {
            holder.textView.text = datas[position]
            holder.itemView.setOnClickListener {
                onItemClick()
            }
        }

        override fun getItemCount(): Int = datas.size

        class ViewHolder(itemView: FrameLayout) : RecyclerView.ViewHolder(itemView) {
            val textView = TextView(itemView.context)

            init {
                textView.setTextColor(0xff000000.toInt())
                textView.setPadding(40, 40, 40, 40)
                textView.textSize = 15f
                itemView.addView(textView)
            }
        }
    }
}

fragment_first.xml

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</FrameLayout>

SecondFragment.kt

class SecondFragment :Fragment(){
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_second,container,false)
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        //如果不写这几行代码,就不会请求back button的焦点
        //activity!!.findViewById<View>(R.id.back).also {
        //    it.isFocusableInTouchMode =true
        //    it.requestFocus()
        //}
    }
}

fragment_second.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#fff">

</androidx.constraintlayout.widget.ConstraintLayout>

回到开发

AccessibilityTraversal

上面提到焦点,所以在这里提一个和焦点有关的功能,accessibilityTraversalid。
使用该属性可以更改手指滑动后选择的View的顺序。比如屏幕里面有a、b、c3个View,正常情况,当a View获取到焦点之后,手指向右滑动会聚焦到b View。此时,如果希望向右滑动后聚焦到c View,就可以使用该属性。
可能有人会想,这个属性究竟有什么用?这个属性的作用其实还是很大的,有些layout的父layout使用的是约束布局。此时,整个界面看起来很正常,但子View顺序是不正常的。此时,如果使用手指向右滑动,就会按照子View的顺序进行滑动,这样可能就会出现奇怪的顺序。遇到这种情况,就可以使用这个属性来解决。
accessibilityTraversal有两个属性:accessibilityTraversalBeforeaccessibilityTraversalAfter

  • before: 顺序在某个View之前。
  • after: 顺序在某个View之后。

比如

<LinearLayout
        android:id="@+id/test_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <TextView
            android:id="@+id/aaa_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:focusable="true"
            android:accessibilityTraversalBefore="@id/ccc_tv"
            android:accessibilityTraversalAfter="@id/bbb_tv"
            android:paddingStart="20dp"
            android:paddingTop="20dp"
            android:paddingEnd="20dp"
            android:text="aaa text" />

        <TextView
            android:id="@+id/bbb_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingStart="20dp"
            android:paddingTop="20dp"
            android:paddingEnd="20dp"
            android:text="bbb text" />

        <TextView
            android:id="@+id/ccc_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:paddingStart="20dp"
            android:paddingTop="20dp"
            android:paddingEnd="20dp"
            android:text="ccc text" />
</LinearLayout>

下面这段代码,在设置before和after之前,顺序是aaa -> bbb -> cccc。
而将a的before设置为c之后,如果焦点在a,并且手指向右滑动,焦点就会跑到c,而不是b。
同理,这里的after是b,说明a在b之后,所以如果焦点在b,向右滑动之后焦点就会到a而不是c。
当然了,可能有人会好奇如果将b的before设置为c会怎么样?所以我也测了一下,设置完没有效果。如果把a的before和after去掉之后,就会有效果。
最后贴一下官方注释吧,一般情况下,需求也不会过于离谱,所以正常地使用就行了。

// before
Sets the id of a view before which this one is visited in accessibility traversal.
A screen-reader must visit the content of this view before the content of the one
it precedes. For example, if view B is set to be before view A, then a screen-reader
will traverse the entire content of B before traversing the entire content of A,
regardles of what traversal strategy it is using.

Views that do not have specified before/after relationships are traversed in order
determined by the screen-reader.

Setting that this view is before a view that is not important for accessibility
or if this view is not important for accessibility will have no effect as the
screen-reader is not aware of unimportant views.

// after
Sets the id of a view after which this one is visited in accessibility traversal.
A screen-reader must visit the content of the other view before the content of this
one. For example, if view B is set to be after view A, then a screen-reader
will traverse the entire content of A before traversing the entire content of B,
regardles of what traversal strategy it is using.

Views that do not have specified before/after relationships are traversed in order
determined by the screen-reader.

Setting that this view is after a view that is not important for accessibility
or if this view is not important for accessibility will have no effect as the
screen-reader is not aware of unimportant views.

回到开发

数字问题

这里指的问题是:有一些长数字并不是想表达一个具体的数值,它就是一串数字,比如订单id。但accessibility不一定知道是这样的,所以一旦遇到数字,accessibility就直接将数值读出来。比如xxx亿xx万零xx元
先提醒一下,这种测试必须看语音,看toast绝对看不出有什么问题。
比如下面的代码,测试的时候会发现将整个数字的数值读了出来。但需求可能是一个数字一个数字读出来。
之前遇到这个问题的时候,看到有人说用phone的inputText可以解决问题,但实际试了一下,发现不行。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <TextView
        android:id="@+id/text_tv"
        android:layout_width="wrap_content"
        android:textSize="18sp"
        android:textColor="#f00"
        android:text="3427348923432"
        android:layout_height="wrap_content"/>
</LinearLayout>

有三种解决方式
方式一:如果文本里面只有没有其他内容,使用TtsSpan的TYPE_DIGITS解决。

// 这里用到了SpannableString,如果不知道这是一什么东西,可以直接复制代码改一改,或者查一下SpannableString是什么。
val text = text_tv.text.toString()
val ss = SpannableString(text)
ss.setSpan(TtsSpan.DigitsBuilder(text).build(),0,text.length,SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
text_tv.text = ss

方式二:如果文本掺杂了和数字无关的中文,但没有英文单词,使用TtsSpan的TYPE_VERBATIM。这个我试过英文单词,会将英文单词的每个字母读出来,而不是将整个单词读出来。

val text = text_tv.text.toString()
val ss = SpannableString(text)
ss.setSpan(TtsSpan.VerbatimBuilder(text).build(),0,text.length,SpannableString.SPAN_INCLUSIVE_EXCLUSIVE)
text_tv.text = ss

方式三:如果上面两种都没办法解决,那就只能使用最后的武器:修改contentDescription。给每个数字中间塞空格。

val text = text_tv.text.toString()
text_tv.contentDescription = numberTextToDigital(text)

private fun numberTextToDigital(numberText: String): String {
    return numberText.replace("(\\d)(?=\\d)".toRegex(),"$1 "))
}

回到开发

长文本问题

问题:当一个界面有很多段文本的时候,一般情况下,会直接使用一个TextView.setText去解决,在不考虑accessibility的问题的时候,这种做法一点问题都没有。但当需要考虑accessibility的时候,就有问题了。假设,一个用户滑动到这个界面的最底部或者中部,然后再点击这个TextView,这个时候就会出现一个问题,直接将这整个TextView的所有文本从头到尾全部读出来。乍一看,好像会觉得这没问题啊。但换位思考一下,如果不管点击到哪里,都需要从头听到尾,不会觉得很浪费时间吗?
解决方案:我的解决方案不是特别好,更好的方式我觉得是深入了解accessibility出现方框的原理,然后根据原理设计一套解决方案。
我的解决方式就是:将一段长文本里面的\n作为分隔符,分隔成一个String Array,然后用一个LinearLayout n个TextView进去,这样当用户点击到某一段文本的时候,实际上点击的就是某一个TextView,这个时候就只会将这个TextView的内容读出来。
这种需求一看就知道是那种常见的需求,所以必须继承LinearLayout自定义一个View,将这些逻辑写到这个View里面,这样才不用每次都写一遍重复代码。

interface LongTextAttribute {
    var textSize: Float?
    var textColor: Int?
    var typeFace: Typeface?
    var isHeading: Boolean?
    var lineHeight: Float?
    var textMarginStart: Float?
    var textMarginTop: Float?
    var textMarginEnd: Float?
    var textMarginBottom: Float?
}

open class LongTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
    LinearLayout(context, attrs, defStyle), LongTextAttribute {

    var text: CharSequence? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var textSize: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var textColor: Int? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }
    override var typeFace: Typeface? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }
    override var isHeading: Boolean? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var lineHeight: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var textMarginStart: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var textMarginTop: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    override var textMarginEnd: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }
    override var textMarginBottom: Float? = null
        set(value) {
            if (field == value) {
                return
            }
            field = value
            updateTextList()
        }

    init {
        super.setOrientation(VERTICAL)
    }

    override fun setOrientation(orientation: Int) {
    }

    private fun updateTextList() {
        text?.also { text ->
            updateLongTextArea(generateLongTextInformationList(text))
        }
    }

    private fun generateLongTextInformationList(longText: CharSequence): List<LongTextInformation> {
        val longTextList = splitLongText(longText)
        return longTextList.map { text ->
            LongTextInformation().also {
                it.text = text
                it.textSize = textSize
                it.textColor = textColor
                it.typeFace = typeFace
                it.isHeading = isHeading
                it.lineHeight = lineHeight
                it.textMarginStart = textMarginStart
                it.textMarginTop = textMarginTop
                it.textMarginEnd = textMarginEnd
                it.textMarginBottom = textMarginBottom
            }
        }.also {
            onGeneratedLongTextInformationList(it)
        }
    }

    private fun updateLongTextArea(longTextInformationList: List<LongTextInformation>) {
        removeAllViews()
        if (longTextInformationList.isEmpty()) {
            return
        }
        longTextInformationList.forEach { information ->
            val textView = generateNewTextView(information)
            textView.text = information.text
            addView(textView)
        }
    }

    private fun generateNewTextView(longTextInformation: LongTextInformation): TextView {
        return AppCompatTextView(context).also {
            val layoutParams = LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
            )
            (longTextInformation.textSize ?: TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_SP, 16f, context.resources.displayMetrics
            )).also { textSize ->
                it.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
            }
            it.setTextColor(longTextInformation.textColor ?: Color.BLACK)
            longTextInformation.typeFace?.also { typeFace ->
                it.typeface = typeFace
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                it.isAccessibilityHeading = longTextInformation.isHeading ?: false
            }
            longTextInformation.lineHeight?.also { lineHeight ->
                it.lineHeight = lineHeight.toInt()
            }
            longTextInformation.textContentDescription?.also { textContentDescription ->
                it.contentDescription = textContentDescription
            }
            longTextInformation.textMarginStart?.also { textMarginStart ->
                layoutParams.marginStart = textMarginStart.toInt()
            }
            longTextInformation.textMarginTop?.also { textMarginTop ->
                layoutParams.topMargin = textMarginTop.toInt()
            }
            longTextInformation.textMarginEnd?.also { textMarginEnd ->
                layoutParams.marginEnd = textMarginEnd.toInt()
            }
            longTextInformation.textMarginBottom?.also { textMarginBottom ->
                layoutParams.bottomMargin = textMarginBottom.toInt()
            }

            it.layoutParams = layoutParams
        }
    }

    protected open fun splitLongText(longText: CharSequence) = longText.split("\n")

    protected open fun onGeneratedLongTextInformationList(informationList: List<LongTextInformation>) {}
}

class LongTextInformation : LongTextAttribute {
    var text: CharSequence? = null
    var textContentDescription: String? = null
    override var textSize: Float? = null
    override var textColor: Int? = null
    override var typeFace: Typeface? = null
    override var isHeading: Boolean? = null
    override var lineHeight: Float? = null
    override var textMarginStart: Float? = null
    override var textMarginTop: Float? = null
    override var textMarginEnd: Float? = null
    override var textMarginBottom: Float? = null
}

稍微解释一下代码

  • LongTextAttribute:用于保证LongTextView和LongTextInformation的属性是一致的(除了text)。当需要加一个属性时,在LongTextAttribute里面加了之后,就会要求LongTextView和LongTextInformation强制实现。而如果不这样做,可能就会出现只将目的属性加到其中某一个,而忘了加到另一个这种情况
  • onGeneratedLongTextInformationList:可能有时需要对特定TextView单独定制。比如我遇到过一个需求,产品那边只给了一对key/value,然后就要求我给某一行加粗,所以用重写这个方法就可以解决。比如加粗那行是第3行,就可以调用informationList[3]或informationList.getOrNull(3)设置一下typeFace。可能这里有人会说如果行数变了怎么办,这个确实没办法,只能手动修改。只是我认为这种场景应该不多,所以才敢这样做。不过更好的方式当然是让产品提供多对key/value
  • 默认值:里面的textSize和textColor的默认值都是我随便写的,如果项目中有常用或默认的textSize和textColor,建议手动修改成自己想要的,否则每次手动改一遍也是挺麻烦的
  • 测试方式:测试也比较简单,可以直接使用,或者继承LongTextView,然后重写onGeneratedLongTextInformationList方法,然后调用list[index]修改里面的数值,看一下是否有效

关于splitLongText:一开始我尝试的方案是判断连续\n的数量,然后根据连续\n的数量做不同的处理,但发现处理的效果并不理想,然后才想着试试split怎么样,结果发现意外地好用,所以就用了这种方式。一开始不使用split,是因为担心split了之后,所有\n都不见了,但实际测试的时候发现,当有3个\n的时候,只有中间的会消失,前后2个都还保留,所以就用了这种方式。不过我也给这个方法提供了open修饰符,所以如果有更合适的实现方式,也可以自己实现。

在这里插入图片描述
在这里插入图片描述
回到开发

监听accessibility的开启和关闭

在开发的时候,可能会出于某种原因,需要监听screen reader是否开启。此时,可以调用accessibility service的方法进行监听。

object AccessibilityHelper {
    fun getAccessibilityService(context: Context): AccessibilityManager?{
        return context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
    }
}

// MainActivity
AccessibilityHelper.getAccessibilityService(this)?.addTouchExplorationStateChangeListener {
    if (it){
        // TODO
    }else{
        // TODO
    }
}

true就是开启了screen reader,false就是关闭screen reader。
然后提醒一下AccessibilityManager有一个removeTouchExplorationStateChangeListener方法,所以最好在界面销毁的时候调用一下。

判断是否开启screen reader

AccessibilityHelper.getAccessibilityService(this)?.isTouchExplorationEnabled

回到开发

监听contentDescription的变化

有时,为了代码写起来更为简洁,会给每个view设置一个contentDescription,然后parent view再监听每个child的变化,更新parent view的contentDescription。这样做不只使代码看起来更简洁,而且如果某个view独立出去也不会影响使用,所以我认为这是一种很好的做法。
但如果使用常规的逻辑去监听,就需要给每个view设置一个listener,然后当contentDescription变化的时候通知listener,这样做是很不方便的。最后经过尝试,我找到一种比较方便的做法。

// 接收contentDescription的通知
ViewCompat.setAccessibilityDelegate(test_layout, object : AccessibilityDelegateCompat(){
    override fun onRequestSendAccessibilityEvent(host: ViewGroup?, child: View?, event: AccessibilityEvent?): Boolean {
        if (event?.eventType == AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION){
            Log.d("AccessibilityActivity","childId:${child?.id} contentDescription:${event.contentDescription}")
        }
        return super.onRequestSendAccessibilityEvent(host, child, event)
    }
})

// 设置contentDescription并发送通知
test_tv.contentDescription = "test contentDescription"
test_tv.parent?.requestSendAccessibilityEvent(test_tv, AccessibilityEvent.obtain(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION).also {
    it.contentDescription = test_tv.contentDescription
})

test_tv那里,第二行构建一个AccessibilityEvent并发送出去,然后用该view的parent view接收通知。
这样做确实可以解决问题,但存在一个问题,那就是每次都要写相同的代码,这样做很麻烦。
所以我提供了两种解决方案。
第一种:扩展方法,java需要写在util里面,不过代码和kotlin相差不大,所以我就不单独提供java的代码。

fun View.updateContentDescription(contentDescriptionValue: CharSequence){
    contentDescription = contentDescriptionValue
    parent?.requestSendAccessibilityEvent(this, AccessibilityEvent.obtain(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION).also {
        it.contentDescription = contentDescriptionValue
    })
}

第二种:对于自定义View来说,可以使用属性委托,这是kotlin自带的一种方式,先提供kotlin的解决代码。

class ContentDescriptionValueDelegate: ReadWriteProperty<View, CharSequence?>{
    override fun getValue(thisRef: View, property: KProperty<*>): CharSequence? = thisRef.contentDescription?.toString()

    override fun setValue(thisRef: View, property: KProperty<*>, value: CharSequence?) {
        thisRef.apply {
            contentDescription = value
            parent?.requestSendAccessibilityEvent(this, AccessibilityEvent.obtain(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION).also {
                it.contentDescription = value
            })
        }
    }
}

// 自定义view
class AccessibilityTestLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0):
    LinearLayout(context, attrs, defStyleAttr) {

    var contentDescriptionValue by ContentDescriptionValueDelegate()

}

// 使用
accessibility_test_layout.contentDescriptionValue = "accessibility_test_layout"

java本身没有属性委托这种方式,所以手写类似类似的代码

// ReadWriteProxy
public abstract class ReadWriteProxy<T, V> {
    private final T thisRef;

    public ReadWriteProxy(T thisRef) {
        this.thisRef = thisRef;
    }

    public V getValue() {
        return getValue(thisRef);
    }

    public void setValue(V value) {
        setValue(thisRef, value);
    }

    protected abstract V getValue(T thisRef);

    protected abstract void setValue(T thisRef, V value);
}

// ContentDescriptionValueProxy
public class ContentDescriptionValueProxy extends ReadWriteProxy<View,CharSequence>{
    public ContentDescriptionValueProxy(View thisRef) {
        super(thisRef);
    }

    @Override
    protected CharSequence getValue(View thisRef) {
        return thisRef.getContentDescription();
    }

    @Override
    protected void setValue(View thisRef, CharSequence value) {
        thisRef.setContentDescription(value);
        AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION);
        event.setContentDescription(value);
        thisRef.getParent().requestSendAccessibilityEvent(thisRef,  event);
    }
}

// AccessibilityTestLayout
public class AccessibilityTestLayout extends LinearLayout {
    public ContentDescriptionValueProxy contentDescriptionValue = new ContentDescriptionValueProxy(this);

    public AccessibilityTestLayout(Context context, AttributeSet attrs){
        super(context, attrs);
    }
}

测试代码和上面差不多,我就不贴了,而且我也测试过了,这种做法和kotlin的效果是一样的。

回到开发

给accessibility添加各种action

某些情况下,android系统提供的action没办法满足我们的需求,或者是需要监听某种action。这种行为开发很少会遇到,但至少我遇到了,所以才会想到用这种方式解决。
提一下之前工作中遇到的需求吧,由于一些原因,需要在点击view的时候弹出输入法。而经过测试,accessibility默认是没有提供click事件的,所以需要手动添加一个,并在触发点击的时候显示输入法。

ViewCompat.setAccessibilityDelegate(tv,object :AccessibilityDelegateCompat(){
    override fun onInitializeAccessibilityNodeInfo(host: View?, info: AccessibilityNodeInfoCompat?) {
        super.onInitializeAccessibilityNodeInfo(host, info)
        // 在node info里面添加一个click action
        info?.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
    }
    override fun performAccessibilityAction(host: View?, action: Int, args: Bundle?): Boolean {
        // 判断是否为focus action
        Log.d("AccessibilityActivity","action:$action, ${action == AccessibilityNodeInfoCompat.ACTION_FOCUS}")
        // 判断action是否为click action
        Log.d("AccessibilityActivity","action:$action, ${action == AccessibilityNodeInfoCompat.ACTION_CLICK}")
        return super.performAccessibilityAction(host, action, args)
    }
})

focus action很容易触发,只要单击某个view,就会触发。
而click action则需要在focus之后,快速双击屏幕或该view才会触发。
有一点需要注意。一般情况下,在开启screen reader之后,单击某个view执行的是focus,快速双击才是click。所以想要测试click,需要快速对该viwe双击或者是屏幕双击。
noteInfo还有很多api可以玩一玩,没事的时候可以玩玩,或许以后就能用上。

回到开发

界面穿透问题

不知道有什么词描述这个问题,可能名字起得不是很好,我详细描述一下。

在开发中,有时一个界面遇到网络错误或其他类型的错误,会弹出一个全屏的错误界面。由于我之前所在的项目组的架构是,每个view都是一个component,所以通常会将一个view做完之后,直接放到layout里面,而view的控制逻辑是自己决定的,无需外部去调用。所以很多错误界面我们都是用view去显示,而不是用DialogFragment去显示。
在解决accessibility的问题的时候,才发现这种做法会出现一些accessibility的问题。在显示该错误界面之后,错误界面空白背景的一些看不见的view是可以点击的。而实际上,这些view是看不见的,所以不应该拥有焦点。
在这里插入图片描述
这张图片上,绿色边框是一个textView,并且这个这个view是有text的,只不过现在被"服务不可用"的界面盖住了,所以看不见了,此时是不可用被focus到的。
想解决这种问题有两种简单粗暴的方式。第一种就是我上面提到的,使用DialogFragment,第二种则是,将看些看不见的view gone掉。我第一次遇到这种问题的时候,就是使用gone这种简单粗暴的方式,但后面想了一下,如果一个界面的view比较多,还需要给这些view设置一个viewGroup,否则gone起来就很麻烦了。而且这种方式看起来不也是很优雅,最后我想到了一种看起来比较优雅的方式解决掉大部分问题。
分为两步,一步是使用java/kotlin代码解决,另一步是在xml文件解决。先看java/kotlin部分。

ViewCompat.setAccessibilityDelegate(container, object: AccessibilityDelegateCompat() {
    override fun onRequestSendAccessibilityEvent(host: ViewGroup?, child: View?, event: AccessibilityEvent?): Boolean {
        if (service_unavailable_layout.visibility == View.VISIBLE) {
            if (child == servvice_unavailable_layout) {
                return super.onRequestSendAccessibilityEvent(host, child, event)
            } else {
                return false
            }
        } else {
            return super.onRequestSendAccessibilityEvent(host, child, event)
        }
    }
})

这里的service_unavailable_layout就是这个错误界面的父layout,而container则是整个界面的rootView。
这里的逻辑是,如果service unavailable可见,就判断请求的是不是service unavailable的layout或子view。如果不是,就不要发送此次事件。这里需要注意的是,无论请求的是layout还是子view,这里的child依然是layout文件,想要知道具体是哪个view,需要获取event里面的class name。
我猜测android官方这样设计的原因是,希望每个view group可以自己管理好自己的view,而不是交给其他view来管理。

写了这几行代码之后,已经解决了一个问题,就是没办法通过点击屏幕来focus到那些看不见的view。但想要focus到其他view,不只有点击这种方式,还能在手机屏幕上左右滑动其他view。此时,如果焦点在"返回"按钮上面,再向右滑动,则会focus到第一个看不见的view。此时,就需要在layout文件解决。
由于layout文件的代码太多,我就不贴了,我就写一下怎么做。
首先,从标题到button3个view的id分别是:service_unavailable_tv、service_unavailable_content、back_btn。
在layout属性里面,accessibility有这样两个属性:accessibilityTraversalAfter和accessibilityTraversalBefore。使用这两个属性可以修改accessibility的滑动顺序,after表示在哪个view的后面,before表示在哪个view的前面。
在这个例子中,tv的after是back,before是content。content则是tv和back,button则是content和tv。设置好顺序之后,左右滑动就只会在这3个view中循环,而不会focus到其他view。而如果服务不可用这个界面消失了,就需要将buttion的after和before设置为NO_ID或者其他View的id,断掉这个循环。

还剩下的问题:在第一次显示"服务不可用"的时候,焦点没有在"服务不可用"上面,此时需要调用requestFocus。如果没有生效,就给“服务不可用”设置focusable和focusableInTouchMode为true。

最后补充一下,如果还是想要gone掉,我提供另一种更简单的方法。

// 获取第一层view的list,如果第一层的某个ViewGroup有子view,用这方法将获取不到
fun ViewGroup.firstLevelChildList(): MutableSet<View>{
    val set = LinkedHashSet<View>()
    for (i in 0 until childCount){
        set.add(getChildAt(i))
    }
    return set
}

// 为什么要写上面这个方法,因为如果是View,那直接gone就看不见了
// 如果是ViewGroup,那只要将ViewGroup gone掉,该ViewGroup的子view自然也看不见
// 可能有人会说androidx提供了children这个方法,但使用这个方法获取到的对象只能forEach,没办法remove。不过如果知道其他方法也做到同样的效果,可以发出来

// activity
// container就是根布局的layout
val allChild = container.firstLevelChildList()
// 只需要将service unavailable的layout移出掉就可以
allChild.remove(service_unavailable_layout)
allChild.forEach{
    it.visibility = View.GONE
}

// 作为补充,再提供一个可以获取全部child的方法。这个方法在这里用不上,但应该有用得上的地方吧
fun ViewGroup.childList(): MutableSet<View>{
    val set = LinkedHashSet<View>()
    for (i in 0 until childCount){
        val child = getChildAt(i)
        set.add(child)
        if (child is ViewGroup){
            set.addAll(child.childList())
        }
    }
    return set
}

最后的补充:在写上面这些的几天后,我想到了一种特别简单的解决方案。在该界面加一个空白的view作为第一个子view。比如这个界面的layout是RelativeLayout,那就可以直接写一个宽和高都是match_parent的view,无需viewId/background什么的。然后clickable/importantForAccessibility/focus什么的都可以加上去。这样就可以保证没办法点到后面的view,因为只要点到后面,就会被该view拦截掉。

回到开发

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Android无障碍服务是一种功能,可以帮助用户更轻松地使用设备,包括视觉、听觉、运动和认知障碍。它可以通过提供语音反馈、触觉反馈、放大屏幕等方式来帮助用户。无障碍服务还可以自动化一些任务,例如读取通知、填写表单等。 ### 回答2: AndroidAccessibilityService是一个系统级别的服务,可以帮助用户在Android系统上更好的使用功能,尤其是对于那些具有身体障碍、视觉障碍和听觉障碍等特殊需求的用户来说,AccessibilityService可以提供完美的解决方案,通过对系统上的事件和用户交互进行监听和分析,然后帮助用户轻松的实现他们想要达成的功能。 AccessibilityService的工作原理是通过监听Android系统上的一些事件,比如界面控件的变化、通知的出现、文本输入等等,然后对这些事件进行判断和处理,最终将结果反馈给用户。通过AccessibilityService框架,用户可以设置所需要实现的功能,并在设备中启用对应的服务,这样AccessibilityService便能够按照用户的需求来对事件进行分析,并在需要时向用户发送通知或执行指定的操作。 AccessibilityService在Android的应用场景中得到了广泛的应用,它可以用来优化屏幕导航、改善文字输入、提高阅读体验、帮助用户控制设备等等。比如对于肢体残疾的用户,他们可能难以使用传统的手持设备,但是通过使用可支持无障碍功能的设备,他们可以轻松地使用语音识别来输入文字、使用语音命令来操作设备、使用触控手势来控制屏幕等等。 总之,AccessibilityService为使用Android设备的用户提供了一个更加便捷和自然的界面和服务,有效的帮助了一部分特殊需求的用户,使得智能手持设备向着更加无障碍化、智能化、人性化的方向不断发展和完善。 ### 回答3: Android AccessibilityService是一个服务,它可以让用户在使用设备时更加方便,无论用户是否患有视力或听力障碍。通过使用Android AccessibilityService,开发人员可以改善用户体验,例如提高可访问性、方便操作等。许多应用程序都可以从Android AccessibilityService中受益,这些应用程序包括辅助浏览、读取屏幕、听屏幕等。 Android AccessibilityService的基本原理是将一个服务添加到Android系统中,该服务可以监视系统事件,并在用户发出命令时自动触发相应的功能。例如,当用户在屏幕上点击文本框时,AccessibilityService可以自动弹出一个软件键盘以便用户进行输入。 开发人员可以使用Android AccessibilityService API来开发自己的服务,并实现自己的功能。例如,开发人员可以使用这些API来获取用户在屏幕上执行的所有事件,并根据这些事件来触发相应的功能,例如高亮屏幕,增加音量等。 总之,Android AccessibilityService使得使用设备更加方便,无论用户是否具有视力或听力障碍,而且可以广泛应用于各种应用程序中。开发人员在设计应用程序时应该考虑到这一点,并尽可能地提高应用程序的可访问性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值