Highlight the current talkback-focused element in the accessibility tree
Summary: Highlights the element corresponding to the view talkback is focused on in green in the ax tree (and updates live as talkback moves). Reviewed By: blavalla Differential Revision: D9021542 fbshipit-source-id: c3bf6f5625aacb0cd054032b33a50541b88b2eaf
This commit is contained in:
committed by
Facebook Github Bot
parent
6939292209
commit
33e6538477
@@ -10,6 +10,8 @@ package com.facebook.sonar.plugins.inspector;
|
|||||||
|
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.support.v4.view.ViewCompat;
|
||||||
|
import android.view.accessibility.AccessibilityEvent;
|
||||||
import android.view.MotionEvent;
|
import android.view.MotionEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@@ -170,20 +172,41 @@ public class InspectorSonarPlugin implements SonarPlugin {
|
|||||||
@Override
|
@Override
|
||||||
public void onReceiveOnMainThread(SonarObject params, SonarResponder responder)
|
public void onReceiveOnMainThread(SonarObject params, SonarResponder responder)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
List<View> viewRoots = mApplication.getViewRoots();
|
final List<View> viewRoots = mApplication.getViewRoots();
|
||||||
// for now only works if one view root
|
|
||||||
if (viewRoots.size() != 1) {
|
ViewGroup root = null;
|
||||||
responder.error(
|
for (int i = viewRoots.size() - 1; i >= 0; i--) {
|
||||||
new SonarObject.Builder().put("message", "Too many view roots.").build());
|
if (viewRoots.get(i) instanceof ViewGroup) {
|
||||||
return;
|
root = (ViewGroup) viewRoots.get(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SonarObject response = getAXNode(trackObject(viewRoots.get(0)));
|
|
||||||
if (response == null) {
|
if (root != null) {
|
||||||
responder.error(
|
|
||||||
new SonarObject.Builder().put("message", "AX root node returned null.").build());
|
// unlikely, but check to make sure accessibility functionality doesn't change
|
||||||
return;
|
if (!ViewCompat.hasAccessibilityDelegate(root)) {
|
||||||
|
|
||||||
|
// add delegate to root to catch accessibility events so we can update focus in sonar
|
||||||
|
root.setAccessibilityDelegate(new View.AccessibilityDelegate() {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onRequestSendAccessibilityEvent(ViewGroup host, View child, AccessibilityEvent event) {
|
||||||
|
int eventType = event.getEventType();
|
||||||
|
if (eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED || eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED) {
|
||||||
|
mConnection.send("axFocusEvent",
|
||||||
|
new SonarObject.Builder()
|
||||||
|
.put("isFocus", eventType == AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onRequestSendAccessibilityEvent(host, child, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
responder.success(getAXNode(trackObject(root)));
|
||||||
}
|
}
|
||||||
responder.success(response);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -550,6 +573,7 @@ public class InspectorSonarPlugin implements SonarPlugin {
|
|||||||
.put("data", data)
|
.put("data", data)
|
||||||
.put("children", children)
|
.put("children", children)
|
||||||
.put("attributes", attributes)
|
.put("attributes", attributes)
|
||||||
|
.put("extraInfo", descriptor.getExtraInfo(obj))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ public class TextViewDescriptor extends NodeDescriptor<TextView> {
|
|||||||
return descriptor.getAXAttributes(node);
|
return descriptor.getAXAttributes(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SonarObject getExtraInfo(TextView node) {
|
||||||
|
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
||||||
|
return descriptor.getExtraInfo(node);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setHighlighted(TextView node, boolean selected) throws Exception {
|
public void setHighlighted(TextView node, boolean selected) throws Exception {
|
||||||
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
||||||
|
|||||||
@@ -444,6 +444,7 @@ public class ViewDescriptor extends NodeDescriptor<View> {
|
|||||||
return attributes;
|
return attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<Named<String>> getAXAttributes(View node) throws Exception {
|
public List<Named<String>> getAXAttributes(View node) throws Exception {
|
||||||
List<Named<String>> attributes = new ArrayList<>();
|
List<Named<String>> attributes = new ArrayList<>();
|
||||||
String role = AccessibilityRoleUtil.getRole(node).toString();
|
String role = AccessibilityRoleUtil.getRole(node).toString();
|
||||||
@@ -453,6 +454,11 @@ public class ViewDescriptor extends NodeDescriptor<View> {
|
|||||||
return attributes;
|
return attributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SonarObject getExtraInfo(View node) {
|
||||||
|
return new SonarObject.Builder().put("focused", AccessibilityUtil.isAXFocused(node)).build();
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static String getResourceId(View node) {
|
private static String getResourceId(View node) {
|
||||||
final int id = node.getId();
|
final int id = node.getId();
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ public class ViewGroupDescriptor extends NodeDescriptor<ViewGroup> {
|
|||||||
return descriptor.getAXAttributes(node);
|
return descriptor.getAXAttributes(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SonarObject getExtraInfo(ViewGroup node) {
|
||||||
|
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
||||||
|
return descriptor.getExtraInfo(node);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setHighlighted(ViewGroup node, boolean selected) throws Exception {
|
public void setHighlighted(ViewGroup node, boolean selected) throws Exception {
|
||||||
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
final NodeDescriptor descriptor = descriptorForClass(View.class);
|
||||||
|
|||||||
@@ -420,6 +420,18 @@ public final class AccessibilityUtil {
|
|||||||
return nodeInfoProps.build();
|
return nodeInfoProps.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isAXFocused(View view) {
|
||||||
|
final AccessibilityNodeInfoCompat nodeInfo =
|
||||||
|
ViewAccessibilityHelper.createNodeInfoFromView(view);
|
||||||
|
if (nodeInfo == null) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
boolean focused = nodeInfo.isAccessibilityFocused();
|
||||||
|
nodeInfo.recycle();
|
||||||
|
return focused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be
|
* Modifies a {@link SonarObject.Builder} to add Talkback-specific Accessibiltiy properties to be
|
||||||
* shown in the Sonar Layout Inspector.
|
* shown in the Sonar Layout Inspector.
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ public class LithoViewDescriptor extends NodeDescriptor<LithoView> {
|
|||||||
return descriptor.getAXAttributes(node);
|
return descriptor.getAXAttributes(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SonarObject getExtraInfo(LithoView node) {
|
||||||
|
final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class);
|
||||||
|
return descriptor.getExtraInfo(node);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setHighlighted(LithoView node, boolean selected) throws Exception {
|
public void setHighlighted(LithoView node, boolean selected) throws Exception {
|
||||||
final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class);
|
final NodeDescriptor descriptor = descriptorForClass(ViewGroup.class);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class AXElementsInspector extends Component<{
|
|||||||
onElementHovered: ?(key: ?ElementID) => void,
|
onElementHovered: ?(key: ?ElementID) => void,
|
||||||
onValueChanged: ?(path: Array<string>, val: any) => void,
|
onValueChanged: ?(path: Array<string>, val: any) => void,
|
||||||
selected: ?ElementID,
|
selected: ?ElementID,
|
||||||
|
focused: ?ElementID,
|
||||||
searchResults?: ?ElementSearchResultSet,
|
searchResults?: ?ElementSearchResultSet,
|
||||||
root: ?ElementID,
|
root: ?ElementID,
|
||||||
elements: {[key: ElementID]: Element},
|
elements: {[key: ElementID]: Element},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type InspectorState = {|
|
|||||||
AXinitialised: boolean,
|
AXinitialised: boolean,
|
||||||
selected: ?ElementID,
|
selected: ?ElementID,
|
||||||
AXselected: ?ElementID,
|
AXselected: ?ElementID,
|
||||||
|
AXfocused: ?ElementID,
|
||||||
root: ?ElementID,
|
root: ?ElementID,
|
||||||
AXroot: ?ElementID,
|
AXroot: ?ElementID,
|
||||||
elements: {[key: ElementID]: Element},
|
elements: {[key: ElementID]: Element},
|
||||||
@@ -66,6 +67,15 @@ type UpdateElementsArgs = {|
|
|||||||
elements: Array<$Shape<Element>>,
|
elements: Array<$Shape<Element>>,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
|
type UpdateAXElementsArgs = {|
|
||||||
|
elements: Array<$Shape<Element>>,
|
||||||
|
forFocusEvent: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
|
type AXFocusEventResult = {|
|
||||||
|
isFocus: boolean,
|
||||||
|
|};
|
||||||
|
|
||||||
type SetRootArgs = {|
|
type SetRootArgs = {|
|
||||||
root: ElementID,
|
root: ElementID,
|
||||||
|};
|
|};
|
||||||
@@ -159,6 +169,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
|
|||||||
AXroot: null,
|
AXroot: null,
|
||||||
selected: null,
|
selected: null,
|
||||||
AXselected: null,
|
AXselected: null,
|
||||||
|
AXfocused: null,
|
||||||
searchResults: null,
|
searchResults: null,
|
||||||
outstandingSearchQuery: null,
|
outstandingSearchQuery: null,
|
||||||
AXtoNonAXMapping: {},
|
AXtoNonAXMapping: {},
|
||||||
@@ -278,17 +289,30 @@ export default class Layout extends SonarPlugin<InspectorState> {
|
|||||||
return {elements: updatedElements, AXtoNonAXMapping: updatedMapping};
|
return {elements: updatedElements, AXtoNonAXMapping: updatedMapping};
|
||||||
},
|
},
|
||||||
|
|
||||||
UpdateAXElements(state: InspectorState, {elements}: UpdateElementsArgs) {
|
UpdateAXElements(
|
||||||
|
state: InspectorState,
|
||||||
|
{elements, forFocusEvent}: UpdateAXElementsArgs,
|
||||||
|
) {
|
||||||
const updatedElements = state.AXelements;
|
const updatedElements = state.AXelements;
|
||||||
|
|
||||||
|
// if focusEvent, previously focused element can be reset
|
||||||
|
let updatedFocus = forFocusEvent ? null : state.AXfocused;
|
||||||
|
|
||||||
for (const element of elements) {
|
for (const element of elements) {
|
||||||
|
if (element.extraInfo.focused) {
|
||||||
|
updatedFocus = element.id;
|
||||||
|
}
|
||||||
const current = updatedElements[element.id] || {};
|
const current = updatedElements[element.id] || {};
|
||||||
updatedElements[element.id] = {
|
updatedElements[element.id] = {
|
||||||
...current,
|
...current,
|
||||||
...element,
|
...element,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {AXelements: updatedElements};
|
|
||||||
|
return {
|
||||||
|
AXelements: updatedElements,
|
||||||
|
AXfocused: updatedFocus,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
SetRoot(state: InspectorState, {root}: SetRootArgs) {
|
SetRoot(state: InspectorState, {root}: SetRootArgs) {
|
||||||
@@ -442,6 +466,34 @@ export default class Layout extends SonarPlugin<InspectorState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.client.subscribe('axFocusEvent', (focusEvent: AXFocusEventResult) => {
|
||||||
|
if (AXToggleButtonEnabled) {
|
||||||
|
// if focusing, need to update all elements in the tree because
|
||||||
|
// we don't know which one now has focus
|
||||||
|
const keys = focusEvent.isFocus
|
||||||
|
? Object.keys(this.state.AXelements)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// if unfocusing and currently focused element exists, update only the
|
||||||
|
// focused element (and only if it is loaded in tree)
|
||||||
|
if (
|
||||||
|
!focusEvent.isFocus &&
|
||||||
|
this.state.AXfocused &&
|
||||||
|
this.state.AXelements[this.state.AXfocused]
|
||||||
|
) {
|
||||||
|
keys.push(this.state.AXfocused);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getNodes(keys, true, true).then((elements: Array<Element>) => {
|
||||||
|
this.dispatchAction({
|
||||||
|
elements,
|
||||||
|
forFocusEvent: true,
|
||||||
|
type: 'UpdateAXElements',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.client.subscribe(
|
this.client.subscribe(
|
||||||
'invalidate',
|
'invalidate',
|
||||||
({nodes}: {nodes: Array<{id: ElementID}>}) => {
|
({nodes}: {nodes: Array<{id: ElementID}>}) => {
|
||||||
@@ -713,6 +765,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
|
|||||||
AXinitialised,
|
AXinitialised,
|
||||||
selected,
|
selected,
|
||||||
AXselected,
|
AXselected,
|
||||||
|
AXfocused,
|
||||||
root,
|
root,
|
||||||
AXroot,
|
AXroot,
|
||||||
elements,
|
elements,
|
||||||
@@ -792,6 +845,7 @@ export default class Layout extends SonarPlugin<InspectorState> {
|
|||||||
onElementExpanded={this.onElementExpanded}
|
onElementExpanded={this.onElementExpanded}
|
||||||
onValueChanged={this.onDataValueChanged}
|
onValueChanged={this.onDataValueChanged}
|
||||||
selected={AXselected}
|
selected={AXselected}
|
||||||
|
focused={AXfocused}
|
||||||
searchResults={null}
|
searchResults={null}
|
||||||
root={AXroot}
|
root={AXroot}
|
||||||
elements={AXelements}
|
elements={AXelements}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type ElementAttribute = {|
|
|||||||
export type ElementExtraInfo = {|
|
export type ElementExtraInfo = {|
|
||||||
nonAXWithAXChild?: boolean,
|
nonAXWithAXChild?: boolean,
|
||||||
linkedAXNode?: string,
|
linkedAXNode?: string,
|
||||||
|
focused?: boolean,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
export type Element = {|
|
export type Element = {|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ const ElementsRowContainer = ContextMenu.extends(
|
|||||||
backgroundColor: props => {
|
backgroundColor: props => {
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
return colors.macOSTitleBarIconSelected;
|
return colors.macOSTitleBarIconSelected;
|
||||||
|
} else if (props.focused) {
|
||||||
|
return colors.lime;
|
||||||
} else if (props.even) {
|
} else if (props.even) {
|
||||||
return colors.light02;
|
return colors.light02;
|
||||||
} else {
|
} else {
|
||||||
@@ -47,12 +49,20 @@ const ElementsRowContainer = ContextMenu.extends(
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
||||||
'& *': {
|
'& *': {
|
||||||
color: props => (props.selected ? `${colors.white} !important` : ''),
|
color: props =>
|
||||||
|
props.selected || props.focused ? `${colors.white} !important` : '',
|
||||||
},
|
},
|
||||||
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: props =>
|
backgroundColor: props => {
|
||||||
props.selected ? colors.macOSTitleBarIconSelected : '#EBF1FB',
|
if (props.selected) {
|
||||||
|
return colors.macOSTitleBarIconSelected;
|
||||||
|
} else if (props.focused) {
|
||||||
|
return colors.lime;
|
||||||
|
} else {
|
||||||
|
return '#EBF1FB';
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -122,7 +132,9 @@ class PartialHighlight extends PureComponent<{
|
|||||||
static HighlightedText = styled.text({
|
static HighlightedText = styled.text({
|
||||||
backgroundColor: '#ffff33',
|
backgroundColor: '#ffff33',
|
||||||
color: props =>
|
color: props =>
|
||||||
props.selected ? `${colors.grapeDark3} !important` : 'auto',
|
props.selected || props.focused
|
||||||
|
? `${colors.grapeDark3} !important`
|
||||||
|
: 'auto',
|
||||||
});
|
});
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -162,6 +174,7 @@ class ElementsRowAttribute extends PureComponent<{
|
|||||||
value: string,
|
value: string,
|
||||||
matchingSearchQuery: ?string,
|
matchingSearchQuery: ?string,
|
||||||
selected: boolean,
|
selected: boolean,
|
||||||
|
focused: boolean,
|
||||||
}> {
|
}> {
|
||||||
render() {
|
render() {
|
||||||
const {name, value, matchingSearchQuery, selected} = this.props;
|
const {name, value, matchingSearchQuery, selected} = this.props;
|
||||||
@@ -195,6 +208,7 @@ type ElementsRowProps = {
|
|||||||
id: ElementID,
|
id: ElementID,
|
||||||
level: number,
|
level: number,
|
||||||
selected: boolean,
|
selected: boolean,
|
||||||
|
focused: boolean,
|
||||||
matchingSearchQuery: ?string,
|
matchingSearchQuery: ?string,
|
||||||
element: Element,
|
element: Element,
|
||||||
even: boolean,
|
even: boolean,
|
||||||
@@ -268,6 +282,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
|
|||||||
id,
|
id,
|
||||||
level,
|
level,
|
||||||
selected,
|
selected,
|
||||||
|
focused,
|
||||||
style,
|
style,
|
||||||
even,
|
even,
|
||||||
matchingSearchQuery,
|
matchingSearchQuery,
|
||||||
@@ -295,6 +310,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
|
|||||||
value={attr.value}
|
value={attr.value}
|
||||||
matchingSearchQuery={matchingSearchQuery}
|
matchingSearchQuery={matchingSearchQuery}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
focused={focused}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: [];
|
: [];
|
||||||
@@ -327,6 +343,7 @@ class ElementsRow extends PureComponent<ElementsRowProps, ElementsRowState> {
|
|||||||
key={id}
|
key={id}
|
||||||
level={level}
|
level={level}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
focused={focused}
|
||||||
matchingSearchQuery={matchingSearchQuery}
|
matchingSearchQuery={matchingSearchQuery}
|
||||||
even={even}
|
even={even}
|
||||||
onClick={this.onClick}
|
onClick={this.onClick}
|
||||||
@@ -368,6 +385,7 @@ const ElementsBox = FlexColumn.extends({
|
|||||||
type ElementsProps = {|
|
type ElementsProps = {|
|
||||||
root: ?ElementID,
|
root: ?ElementID,
|
||||||
selected: ?ElementID,
|
selected: ?ElementID,
|
||||||
|
focused?: ?ElementID,
|
||||||
searchResults: ?ElementSearchResultSet,
|
searchResults: ?ElementSearchResultSet,
|
||||||
elements: {[key: ElementID]: Element},
|
elements: {[key: ElementID]: Element},
|
||||||
onElementSelected: (key: ElementID) => void,
|
onElementSelected: (key: ElementID) => void,
|
||||||
@@ -532,6 +550,7 @@ export class Elements extends PureComponent<ElementsProps, ElementsState> {
|
|||||||
onElementHovered,
|
onElementHovered,
|
||||||
onElementSelected,
|
onElementSelected,
|
||||||
selected,
|
selected,
|
||||||
|
focused,
|
||||||
searchResults,
|
searchResults,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const {flatElements} = this.state;
|
const {flatElements} = this.state;
|
||||||
@@ -557,6 +576,7 @@ export class Elements extends PureComponent<ElementsProps, ElementsState> {
|
|||||||
onElementHovered={onElementHovered}
|
onElementHovered={onElementHovered}
|
||||||
onElementSelected={onElementSelected}
|
onElementSelected={onElementSelected}
|
||||||
selected={selected === row.key}
|
selected={selected === row.key}
|
||||||
|
focused={focused === row.key}
|
||||||
matchingSearchQuery={
|
matchingSearchQuery={
|
||||||
searchResults && searchResults.matches.has(row.key)
|
searchResults && searchResults.matches.has(row.key)
|
||||||
? searchResults.query
|
? searchResults.query
|
||||||
|
|||||||
Reference in New Issue
Block a user