使用虚拟 DPAD 在 Xamarin 应用程序中处理对讲

Handle Talkback in a Xamarin app using a virtual DPAD

我有一个 Xamarin 应用程序,它不打算处理 android 的对讲功能,因为它必须以特定方式构建才能正常工作。

我的应用程序有点乱,我无法完全重做。

所以,这是怎么回事? 我的 Xamarin 应用程序是使用非本机库制作的,Talkback 不支持这些库,因此,当用户打开 Talkback 功能时,应用程序实际上停止接收 DPAD 事件,因为它们由系统辅助功能服务处理。

该服务获取事件,并尝试在我的应用程序中处理它们,但是,由于我的组件不是本地组件,系统无法识别它们并且 DPAD 被浪费了,因此产生了 DPAD 的错觉不工作。

那么,如果您只想在启用对讲的情况下自己处理 DPAD(而不是其他任何东西),您必须做什么?

此 post 的答案将包含描述以下行为的代码:

1. The talkback wont be able to 'talk' about your components

2. The DPAD events will be handled by an Accessibility Delegate

3. A virtual DPAD will handle the navigation

4. The green rectangle used for focus will be disabled, since you wont need it anyway

5. The app will look exactly the same with Talkback on and off

这个 post 是出于教育目的而制作的,因为我很难想出解决方案,希望下一个人觉得它有用。

第一步是创建一个 class 继承 AccessibilityDelegateCompat 以创建我们自己的辅助功能服务。

class MyAccessibilityHelper : AccessibilityDelegateCompat
{
    const string Tag = "MyAccessibilityHelper";
    const int ROOT_NODE = -1;
    const int INVALID_NODE = -1000;
    const string NODE_CLASS_NAME = "My_Node";

    public const int NODE_UP = 1;
    public const int NODE_LEFT = 2;
    public const int NODE_CENTER = 3;
    public const int NODE_RIGHT = 4;
    public const int NODE_DOWN = 5;

    private class MyAccessibilityProvider : AccessibilityNodeProviderCompat
    {
        private readonly MyAccessibilityHelper mHelper;

        public MyAccessibilityProvider(MyAccessibilityHelper helper)
        {
            mHelper = helper;
        }

        public override bool PerformAction(int virtualViewId, int action, Bundle arguments)
        {
            return mHelper.PerformNodeAction(virtualViewId, action, arguments);
        }

        public override AccessibilityNodeInfoCompat CreateAccessibilityNodeInfo(int virtualViewId)
        {
            var node = mHelper.CreateNode(virtualViewId);
            return AccessibilityNodeInfoCompat.Obtain(node);
        }
    }

    private readonly View mView;
    private readonly MyAccessibilityProvider mProvider;
    private Dictionary<int, Rect> mRects = new Dictionary<int, Rect>();
    private int mAccessibilityFocusIndex = INVALID_NODE;

    public MyAccessibilityHelper(View view)
    {
        mView = view;
        mProvider = new MyAccessibilityProvider(this);
    }

    public override AccessibilityNodeProviderCompat GetAccessibilityNodeProvider(View host)
    {
        return mProvider;
    }

    public override void SendAccessibilityEvent(View host, int eventType)
    {
        Android.Util.Log.Debug(Tag, "SendAccessibilityEvent: host={0} eventType={1}", host, eventType);
        base.SendAccessibilityEvent(host, eventType);
    }

    public void AddRect(int id, Rect rect)
    {
        mRects.Add(id, rect);
    }

    public AccessibilityNodeInfoCompat CreateNode(int virtualViewId)
    {
        var node = AccessibilityNodeInfoCompat.Obtain(mView);
        if (virtualViewId == ROOT_NODE)
        {
            node.ContentDescription = "Root node";
            ViewCompat.OnInitializeAccessibilityNodeInfo(mView, node);
            foreach (var r in mRects)
            {
                node.AddChild(mView, r.Key);
            }
        }
        else
        {
            node.ContentDescription = "";
            node.ClassName = NODE_CLASS_NAME;
            node.Enabled = true;
            node.Focusable = true;
            var r = mRects[virtualViewId];
            node.SetBoundsInParent(r);
            int[] offset = new int[2];
            mView.GetLocationOnScreen(offset);
            node.SetBoundsInScreen(new Rect(offset[0] + r.Left, offset[1] + r.Top, offset[0] + r.Right, offset[1] + r.Bottom));
            node.PackageName = mView.Context.PackageName;
            node.SetSource(mView, virtualViewId);
            node.SetParent(mView);
            node.VisibleToUser = true;
            if (virtualViewId == mAccessibilityFocusIndex)
            {
                node.AccessibilityFocused = true;
                node.AddAction(AccessibilityNodeInfoCompat.ActionClearAccessibilityFocus);
            }
            else
            {
                node.AccessibilityFocused = false;
                node.AddAction(AccessibilityNodeInfoCompat.FocusAccessibility);
            }
        }
        return node;
    }

    private AccessibilityEvent CreateEvent(int virtualViewId, EventTypes eventType)
    {
        var e = AccessibilityEvent.Obtain(eventType);
        if (virtualViewId == ROOT_NODE)
        {
            ViewCompat.OnInitializeAccessibilityEvent(mView, e);
        }
        else
        {
            var record = AccessibilityEventCompat.AsRecord(e);
            record.Enabled = true;
            record.SetSource(mView, virtualViewId);
            record.ClassName = NODE_CLASS_NAME;
            e.PackageName = mView.Context.PackageName;
        }
        return e;
    }

    public bool SendEventForVirtualView(int virtualViewId, EventTypes eventType)
    {
        if (mView.Parent == null)
            return false;
        var e = CreateEvent(virtualViewId, eventType);
        return ViewParentCompat.RequestSendAccessibilityEvent(mView.Parent, mView, e);
    }

    public bool PerformNodeAction(int virtualViewId, int action, Bundle arguments)
    {
        if (virtualViewId == ROOT_NODE)
        {
            return ViewCompat.PerformAccessibilityAction(mView, action, arguments);
        }
        else
        {
            switch (action)
            {
                case AccessibilityNodeInfoCompat.ActionAccessibilityFocus:
                    if (virtualViewId != mAccessibilityFocusIndex)
                    {
                        if (mAccessibilityFocusIndex != INVALID_NODE)
                        {
                            SendEventForVirtualView(mAccessibilityFocusIndex, EventTypes.ViewAccessibilityFocusCleared);
                        }
                        mAccessibilityFocusIndex = virtualViewId;
                        mView.Invalidate();
                        SendEventForVirtualView(virtualViewId, EventTypes.ViewAccessibilityFocused);
                        // virtual key event                            
                        switch (virtualViewId)
                        {
                            case NODE_UP:
                                HandleDpadEvent(Keycode.DpadUp);
                                break;
                            case NODE_LEFT:
                                HandleDpadEvent(Keycode.DpadLeft);
                                break;
                            case NODE_RIGHT:
                                HandleDpadEvent(Keycode.DpadRight);
                                break;
                            case NODE_DOWN:
                                HandleDpadEvent(Keycode.DpadDown); 
                                break;
                        }
                        // refocus center
                        SendEventForVirtualView(NODE_CENTER, EventTypes.ViewAccessibilityFocused);
                        return true;
                    }
                    break;
                case AccessibilityNodeInfoCompat.ActionClearAccessibilityFocus:
                    mView.RequestFocus();
                    if (virtualViewId == mAccessibilityFocusIndex)
                    {
                        mAccessibilityFocusIndex = INVALID_NODE;
                        mView.Invalidate();
                        SendEventForVirtualView(virtualViewId, EventTypes.ViewAccessibilityFocusCleared);
                        return true;
                    }
                    break;
            }
        }
        return false;
    }

    private void HandleDpadEvent(Keycode keycode)
    {
       //Here you know what DPAD was pressed
       //You can create your own key event and send it to your app
       //This code depends on your own application, and I wont be providing the code
       //Note, it is important to handle both, the KeyDOWN and the KeyUP event for it to work
    }
}

由于代码有点大,我只解释关键部分。 一旦对讲激活,字典(从我们下面的视图)将用于创建我们的虚拟 DPAD 的虚拟树节点。考虑到这一点,函数 PerformNodeAction 将是最重要的。

它根据提供的虚拟元素的id,处理虚拟节点被Accessibility系统聚焦后的动作,有两部分,第一个是ROOT_NODE,也就是视图iteslf 包含我们的虚拟方向键,大部分可以忽略,但第二部分是完成处理的地方。

第二部分是处理 ActionAccessibilityFocus 和 ActionClearAccessibilityFocus 动作的地方。 中的两个都很重要,但第一个是我们最终可以处理我们的虚拟 dpad 的地方。

这里所做的是,根据字典中提供的虚拟 ID,我们知道 selected (virtualViewId) 是哪个 DPAD。基于selected DPAD,我们可以在HandleDpadEvent函数中执行我们想要的动作。需要注意的重要一点是,在我们处理 selecteds DPAD 事件之后,我们将重新聚焦我们的 CENTER 节点,以便准备好处理下一次按钮按下。这非常重要,因为,您不想发现自己处于先向下然后向上的情况,只是为了让虚拟方向键聚焦 CENTER 面板。

所以,我会再说一遍,在处理前一个 DPAD 事件后,需要重新调整 CENTER pad 的焦点,以便我们准确知道在按下下一个 DPAD 按钮后我们将在何处!

这里有一个我不会 post 的函数,因为它的代码对我的应用程序来说非常具体,该函数是 HandleDpadEvent,您必须在其中创建一个 keydown 和 keyup 事件并将其发送到您的主要 activity 函数 onKeyDown/Up 将被触发。一旦你这样做了,委托就完成了。

一旦委托完成,我们必须像这样制作我们的视图:

/**
* SimplestCustomView
*/
public class AccessibilityHelperView : View
{
    private MyAccessibilityHelper mHelper;

    Dictionary<int, Rect> virtualIdRectMap = new Dictionary<int, Rect>();

    public AccessibilityHelperView(Context context) :
        base(context)
    {
        Init();
    }

    public AccessibilityHelperView(Context context, IAttributeSet attrs) :
        base(context, attrs)
    {
        Init();
    }

    public AccessibilityHelperView(Context context, IAttributeSet attrs, int defStyle) :
        base(context, attrs, defStyle)
    {
        Init();
    }

    public void Init()
    {
        this.SetFocusable(ViewFocusability.Focusable);
        this.Focusable = true;
        this.FocusedByDefault = true;

        setRectangle();

        mHelper = new MyAccessibilityHelper(this);
        ViewCompat.SetAccessibilityDelegate(this, mHelper);
        foreach (var r in virtualIdRectMap)
        {
            mHelper.AddRect(r.Key, r.Value);
        }
    }

    private void setRectangle()
    {
        virtualIdRectMap.Add(MRAccessibilityHelper.NODE_CENTER, new Rect(1, 1, 2, 2));
        virtualIdRectMap.Add(MRAccessibilityHelper.NODE_LEFT, new Rect(0, 1, 1, 2));
        virtualIdRectMap.Add(MRAccessibilityHelper.NODE_UP, new Rect(1, 0, 2, 1));
        virtualIdRectMap.Add(MRAccessibilityHelper.NODE_RIGHT, new Rect(2, 1, 3, 2));
        virtualIdRectMap.Add(MRAccessibilityHelper.NODE_DOWN, new Rect(1, 2, 2, 3));
    }

    protected override void OnDraw(Canvas canvas)
    {
        base.OnDraw(canvas);
    }
}

该视图如下所示:

需要注意什么?

  1. 节点板的大小以像素为单位,它们位于您应用的左上角。

  2. 它们被设置为单个像素大小,因为对讲功能否则 select 添加到字典中的第一个节点垫带有绿色矩形(这是对讲的标准行为)

  3. 视图中的所有矩形都添加到字典中,将在我们自己的 Accessibility Delegate 中使用,这里要提到的是 CENTER pad 是最先添加的,因此会一次聚焦默认开启对讲

  4. 初始化函数

Init 函数对此很重要,我们将在其中创建我们的视图,并设置一些必要的对讲参数,以便我们的虚拟 dpad 能够被系统自己的辅助功能服务识别。

此外,我们的 Accessibility Delegate 将被初始化,我们的字典将包含所有已创建的 DPAD。

好了,到此为止,我们制作了一个Delegate和一个View,我把它们放在了同一个文件中,这样它们就可以互相看到了。但这不是必须的。

那现在呢?我们必须将 AccessibilityHelperView 添加到我们的应用程序中,在 MainActivity.cs 文件

AccessibilityHelperView mAccessibilityHelperView;

在OnCreate函数中,可以添加如下代码来启动视图:

mAccessibilityHelperView = new AccessibilityHelperView(this);

在OnResume函数中,您可以检查对讲是否打开或关闭,根据结果,您可以在您的mBackgroundLayout(AddView和RemoveView)中添加或删除mAccessibilityHelperView。

OnResume 函数应如下所示:

 if (TalkbackEnabled && !_isVirtualDPadShown)
 {
     mBackgroundLayout.AddView(mAccessibilityHelperView);
     _isVirtualDPadShown = true;
 }
 else if (!TalkbackEnabled && _isVirtualDPadShown)
 {
      mBackgroundLayout.RemoveView(mAccessibilityHelperView);
      _isVirtualDPadShown = false;
 }

TalkbackEnabled 变量是一个本地变量,用于检查 Talkback 服务是打开还是关闭,如下所示:

 public bool TalkbackEnabled 
 {
        get
        {
            AccessibilityManager am = MyApp.Instance.GetSystemService(Context.AccessibilityService) as AccessibilityManager;
            if (am == null) return false;

            String TALKBACK_SETTING_ACTIVITY_NAME = "com.android.talkback.TalkBackPreferencesActivity";
            var serviceList = am.GetEnabledAccessibilityServiceList(FeedbackFlags.AllMask);
            foreach (AccessibilityServiceInfo serviceInfo in serviceList)
            {
                String name = serviceInfo.SettingsActivityName;
                if (name.Equals(TALKBACK_SETTING_ACTIVITY_NAME))
                {
                    Log.Debug(LogArea, "Talkback is active");
                    return true;
                }
            }
            Log.Debug(LogArea, "Talkback is inactive");
            return false;
        }
 }

这应该是让它工作所需的全部内容。

希望能帮到你。